diff --git a/.coveragerc b/.coveragerc
index 35c47de4160..2716a1fed44 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -29,6 +29,7 @@ omit =
homeassistant/components/airly/air_quality.py
homeassistant/components/airly/sensor.py
homeassistant/components/airly/const.py
+ homeassistant/components/airvisual/__init__.py
homeassistant/components/airvisual/sensor.py
homeassistant/components/aladdin_connect/cover.py
homeassistant/components/alarmdecoder/*
@@ -58,14 +59,13 @@ omit =
homeassistant/components/arwn/sensor.py
homeassistant/components/asterisk_cdr/mailbox.py
homeassistant/components/asterisk_mbox/*
- homeassistant/components/asuswrt/device_tracker.py
homeassistant/components/aten_pe/*
homeassistant/components/atome/*
- homeassistant/components/august/*
homeassistant/components/aurora_abb_powerone/sensor.py
homeassistant/components/automatic/device_tracker.py
homeassistant/components/avea/light.py
homeassistant/components/avion/light.py
+ homeassistant/components/avri/sensor.py
homeassistant/components/azure_event_hub/*
homeassistant/components/azure_service_bus/*
homeassistant/components/baidu/tts.py
@@ -108,7 +108,6 @@ omit =
homeassistant/components/canary/alarm_control_panel.py
homeassistant/components/canary/camera.py
homeassistant/components/cast/*
- homeassistant/components/cert_expiry/sensor.py
homeassistant/components/cert_expiry/helper.py
homeassistant/components/channels/*
homeassistant/components/cisco_ios/device_tracker.py
@@ -150,7 +149,6 @@ omit =
homeassistant/components/dht/sensor.py
homeassistant/components/digital_ocean/*
homeassistant/components/digitalloggers/switch.py
- homeassistant/components/directv/media_player.py
homeassistant/components/discogs/sensor.py
homeassistant/components/discord/notify.py
homeassistant/components/dlib_face_detect/image_processing.py
@@ -180,6 +178,7 @@ omit =
homeassistant/components/ecobee/weather.py
homeassistant/components/econet/*
homeassistant/components/ecovacs/*
+ homeassistant/components/edl21/*
homeassistant/components/eddystone_temperature/sensor.py
homeassistant/components/edimax/switch.py
homeassistant/components/egardia/*
@@ -218,6 +217,7 @@ omit =
homeassistant/components/eufy/*
homeassistant/components/everlights/light.py
homeassistant/components/evohome/*
+ homeassistant/components/ezviz/*
homeassistant/components/familyhub/camera.py
homeassistant/components/fastdotcom/*
homeassistant/components/ffmpeg/camera.py
@@ -314,6 +314,7 @@ omit =
homeassistant/components/hydrawise/*
homeassistant/components/hyperion/light.py
homeassistant/components/ialarm/alarm_control_panel.py
+ homeassistant/components/iammeter/sensor.py
homeassistant/components/iaqualink/binary_sensor.py
homeassistant/components/iaqualink/climate.py
homeassistant/components/iaqualink/light.py
@@ -411,7 +412,9 @@ omit =
homeassistant/components/mediaroom/media_player.py
homeassistant/components/melcloud/__init__.py
homeassistant/components/melcloud/climate.py
+ homeassistant/components/melcloud/const.py
homeassistant/components/melcloud/sensor.py
+ homeassistant/components/melcloud/water_heater.py
homeassistant/components/message_bird/notify.py
homeassistant/components/met/weather.py
homeassistant/components/meteo_france/__init__.py
@@ -463,7 +466,6 @@ omit =
homeassistant/components/nello/lock.py
homeassistant/components/nest/*
homeassistant/components/netatmo/__init__.py
- homeassistant/components/netatmo/binary_sensor.py
homeassistant/components/netatmo/api.py
homeassistant/components/netatmo/camera.py
homeassistant/components/netatmo/climate.py
@@ -480,6 +482,7 @@ omit =
homeassistant/components/nissan_leaf/*
homeassistant/components/nmap_tracker/device_tracker.py
homeassistant/components/nmbs/sensor.py
+ homeassistant/components/notion/__init__.py
homeassistant/components/notion/binary_sensor.py
homeassistant/components/notion/sensor.py
homeassistant/components/noaa_tides/sensor.py
@@ -538,10 +541,8 @@ omit =
homeassistant/components/pioneer/media_player.py
homeassistant/components/pjlink/media_player.py
homeassistant/components/plaato/*
- homeassistant/components/plex/__init__.py
homeassistant/components/plex/media_player.py
homeassistant/components/plex/sensor.py
- homeassistant/components/plex/server.py
homeassistant/components/plugwise/*
homeassistant/components/plum_lightpad/*
homeassistant/components/pocketcasts/sensor.py
@@ -565,6 +566,7 @@ omit =
homeassistant/components/qnap/sensor.py
homeassistant/components/qrcode/image_processing.py
homeassistant/components/quantum_gateway/device_tracker.py
+ homeassistant/components/qvr_pro/*
homeassistant/components/qwikswitch/*
homeassistant/components/rachio/*
homeassistant/components/radarr/sensor.py
@@ -698,6 +700,7 @@ omit =
homeassistant/components/tado/device_tracker.py
homeassistant/components/tahoma/*
homeassistant/components/tank_utility/sensor.py
+ homeassistant/components/tankerkoenig/*
homeassistant/components/tapsaff/binary_sensor.py
homeassistant/components/tautulli/sensor.py
homeassistant/components/ted5000/sensor.py
@@ -842,6 +845,7 @@ omit =
homeassistant/components/zha/core/helpers.py
homeassistant/components/zha/core/patches.py
homeassistant/components/zha/core/registries.py
+ homeassistant/components/zha/core/typing.py
homeassistant/components/zha/entity.py
homeassistant/components/zha/light.py
homeassistant/components/zha/sensor.py
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 5b0b8c46e96..2440cb7ff29 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,7 +1,7 @@
blank_issues_enabled: false
contact_links:
- name: Report a bug with the UI, Frontend or Lovelace
- url: https://github.com/home-assistant/home-assistant-polymer/issues
+ url: https://github.com/home-assistant/frontend/issues
about: This is the issue tracker for our backend. Please report issues with the UI in the frontend repository.
- name: Report incorrect or missing information on our website
url: https://github.com/home-assistant/home-assistant.io/issues
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a340aa7ae67..7d55224c335 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,53 +1,58 @@
repos:
-- repo: https://github.com/psf/black
+ - repo: https://github.com/psf/black
rev: 19.10b0
hooks:
- - id: black
+ - id: black
args:
- --safe
- --quiet
files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$
-- repo: https://github.com/codespell-project/codespell
+ - repo: https://github.com/codespell-project/codespell
rev: v1.16.0
hooks:
- - id: codespell
+ - id: codespell
args:
- --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing
- --skip="./.*,*.json"
- --quiet-level=2
exclude_types: [json]
-- repo: https://gitlab.com/pycqa/flake8
+ - repo: https://gitlab.com/pycqa/flake8
rev: 3.7.9
hooks:
- - id: flake8
+ - id: flake8
additional_dependencies:
- flake8-docstrings==1.5.0
- pydocstyle==5.0.2
files: ^(homeassistant|script|tests)/.+\.py$
-- repo: https://github.com/PyCQA/bandit
+ - repo: https://github.com/PyCQA/bandit
rev: 1.6.2
hooks:
- - id: bandit
+ - id: bandit
args:
- --quiet
- --format=custom
- --configfile=tests/bandit.yaml
files: ^(homeassistant|script|tests)/.+\.py$
-- repo: https://github.com/pre-commit/mirrors-isort
+ - repo: https://github.com/pre-commit/mirrors-isort
rev: v4.3.21
hooks:
- - id: isort
-- repo: https://github.com/pre-commit/pre-commit-hooks
+ - id: isort
+ - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.4.0
hooks:
- - id: check-json
-- repo: local
+ - id: check-json
+ - id: no-commit-to-branch
+ args:
+ - --branch=dev
+ - --branch=master
+ - --branch=rc
+ - repo: local
hooks:
- # Run mypy through our wrapper script in order to get the possible
- # pyenv and/or virtualenv activated; it may not have been e.g. if
- # committing from a GUI tool that was not launched from an activated
- # shell.
- - id: mypy
+ # Run mypy through our wrapper script in order to get the possible
+ # pyenv and/or virtualenv activated; it may not have been e.g. if
+ # committing from a GUI tool that was not launched from an activated
+ # shell.
+ - id: mypy
name: mypy
entry: script/run-in-env.sh mypy
language: script
diff --git a/CODEOWNERS b/CODEOWNERS
index e3c0120d816..19bb89ff51e 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -41,6 +41,7 @@ homeassistant/components/auth/* @home-assistant/core
homeassistant/components/automatic/* @armills
homeassistant/components/automation/* @home-assistant/core
homeassistant/components/avea/* @pattyland
+homeassistant/components/avri/* @timvancann
homeassistant/components/awair/* @danielsjf
homeassistant/components/aws/* @awarecan @robbiet480
homeassistant/components/axis/* @kane610
@@ -51,6 +52,7 @@ homeassistant/components/bitcoin/* @fabaff
homeassistant/components/bizkaibus/* @UgaitzEtxebarria
homeassistant/components/blink/* @fronzbot
homeassistant/components/bmw_connected_drive/* @gerard33
+homeassistant/components/bom/* @maddenp
homeassistant/components/braviatv/* @robbiet480
homeassistant/components/broadlink/* @danielhiversen @felipediel
homeassistant/components/brother/* @bieniu
@@ -81,6 +83,7 @@ homeassistant/components/demo/* @home-assistant/core
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/dsmr_reader/* @depl0y
@@ -89,11 +92,13 @@ homeassistant/components/dynalite/* @ziv1234
homeassistant/components/dyson/* @etheralm
homeassistant/components/ecobee/* @marthoc
homeassistant/components/ecovacs/* @OverloadUT
+homeassistant/components/edl21/* @mtdcr
homeassistant/components/egardia/* @jeroenterheerdt
homeassistant/components/eight_sleep/* @mezz64
homeassistant/components/elgato/* @frenck
homeassistant/components/elv/* @majuss
homeassistant/components/emby/* @mezz64
+homeassistant/components/emoncms/* @borpin
homeassistant/components/enigma2/* @fbradyirl
homeassistant/components/enocean/* @bdurrer
homeassistant/components/entur_public_transport/* @hfurubotten
@@ -104,6 +109,7 @@ homeassistant/components/eq3btsmart/* @rytilahti
homeassistant/components/esphome/* @OttoWinter
homeassistant/components/essent/* @TheLastProject
homeassistant/components/evohome/* @zxdavb
+homeassistant/components/ezviz/* @baqs
homeassistant/components/fastdotcom/* @rohankapoorcom
homeassistant/components/file/* @fabaff
homeassistant/components/filter/* @dgomes
@@ -136,6 +142,7 @@ homeassistant/components/google_translate/* @awarecan
homeassistant/components/google_travel_time/* @robbiet480
homeassistant/components/gpsd/* @fabaff
homeassistant/components/greeneye_monitor/* @jkeljo
+homeassistant/components/griddy/* @bdraco
homeassistant/components/group/* @home-assistant/core
homeassistant/components/growatt_server/* @indykoning
homeassistant/components/gtfs/* @robbiet480
@@ -148,7 +155,6 @@ homeassistant/components/hikvision/* @mezz64
homeassistant/components/hikvisioncam/* @fbradyirl
homeassistant/components/hisense_aehw4a1/* @bannhead
homeassistant/components/history/* @home-assistant/core
-homeassistant/components/history_graph/* @andrey-git
homeassistant/components/hive/* @Rendili @KJonline
homeassistant/components/homeassistant/* @home-assistant/core
homeassistant/components/homekit_controller/* @Jc2k
@@ -160,6 +166,7 @@ homeassistant/components/http/* @home-assistant/core
homeassistant/components/huawei_lte/* @scop
homeassistant/components/huawei_router/* @abmantis
homeassistant/components/hue/* @balloob
+homeassistant/components/iammeter/* @lewei50
homeassistant/components/iaqualink/* @flz
homeassistant/components/icloud/* @Quentame
homeassistant/components/ign_sismologia/* @exxamalte
@@ -277,6 +284,7 @@ homeassistant/components/pvoutput/* @fabaff
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/rainbird/* @konikvranik
homeassistant/components/raincloud/* @vanstinator
@@ -287,6 +295,7 @@ homeassistant/components/repetier/* @MTrab
homeassistant/components/rfxtrx/* @danielhiversen
homeassistant/components/ring/* @balloob
homeassistant/components/rmvtransport/* @cgtobi
+homeassistant/components/roku/* @ctalkington
homeassistant/components/roomba/* @pschmitt
homeassistant/components/safe_mode/* @home-assistant/core
homeassistant/components/saj/* @fredericvl
@@ -347,6 +356,7 @@ homeassistant/components/synology_srm/* @aerialls
homeassistant/components/syslog/* @fabaff
homeassistant/components/tado/* @michaelarnauts
homeassistant/components/tahoma/* @philklei
+homeassistant/components/tankerkoenig/* @guillempages
homeassistant/components/tautulli/* @ludeeus
homeassistant/components/tellduslive/* @fredrike
homeassistant/components/template/* @PhracturedBlue @tetienne
@@ -366,7 +376,7 @@ homeassistant/components/traccar/* @ludeeus
homeassistant/components/tradfri/* @ggravlingen
homeassistant/components/trafikverket_train/* @endor-force
homeassistant/components/transmission/* @engrbm87 @JPHutchins
-homeassistant/components/tts/* @robbiet480
+homeassistant/components/tts/* @pvizeli
homeassistant/components/twentemilieu/* @frenck
homeassistant/components/twilio_call/* @robbiet480
homeassistant/components/twilio_sms/* @robbiet480
@@ -376,7 +386,7 @@ homeassistant/components/unifiled/* @florisvdk
homeassistant/components/upc_connect/* @pvizeli
homeassistant/components/upcloud/* @scop
homeassistant/components/updater/* @home-assistant/core
-homeassistant/components/upnp/* @robbiet480
+homeassistant/components/upnp/* @StevenLooman
homeassistant/components/uptimerobot/* @ludeeus
homeassistant/components/usgs_earthquakes_feed/* @exxamalte
homeassistant/components/utility_meter/* @dgomes
@@ -393,7 +403,6 @@ homeassistant/components/vlc_telnet/* @rodripf
homeassistant/components/waqi/* @andrey-git
homeassistant/components/watson_tts/* @rutkai
homeassistant/components/weather/* @fabaff
-homeassistant/components/weblink/* @home-assistant/core
homeassistant/components/webostv/* @bendavid
homeassistant/components/websocket_api/* @home-assistant/core
homeassistant/components/wemo/* @sqldiablo
diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml
index 4c6a353d775..8fb014f80a7 100644
--- a/azure-pipelines-ci.yml
+++ b/azure-pipelines-ci.yml
@@ -148,7 +148,7 @@ stages:
. venv/bin/activate
pytest --timeout=9 --durations=10 -n auto --dist=loadfile --cov homeassistant --cov-report html -qq -o console_output_style=count -p no:sugar tests
- codecov --token $(codecovToken)
+ #codecov --token $(codecovToken)
script/check_dirty
displayName: 'Run pytest for python $(python.container) / coverage'
condition: and(succeeded(), eq(variables['python.container'], variables['PythonMain']))
diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml
index b537aa3bf53..cd04feb4638 100644
--- a/azure-pipelines-wheels.yml
+++ b/azure-pipelines-wheels.yml
@@ -40,7 +40,7 @@ jobs:
if [[ "$(Build.Reason)" =~ (Schedule|Manual) ]]; then
touch requirements_diff.txt
else
- curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt
+ curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/core/master/requirements_all.txt
fi
requirement_files="requirements_wheels.txt requirements_diff.txt"
diff --git a/homeassistant/components/abode/.translations/lv.json b/homeassistant/components/abode/.translations/lv.json
new file mode 100644
index 00000000000..eab98211e14
--- /dev/null
+++ b/homeassistant/components/abode/.translations/lv.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "Parole",
+ "username": "E-pasta adrese"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py
index a5f3e6116a4..666c8481bfb 100644
--- a/homeassistant/components/abode/__init__.py
+++ b/homeassistant/components/abode/__init__.py
@@ -2,7 +2,6 @@
from asyncio import gather
from copy import deepcopy
from functools import partial
-import logging
from abodepy import Abode
from abodepy.exceptions import AbodeException
@@ -24,21 +23,13 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.entity import Entity
-from .const import (
- ATTRIBUTION,
- DEFAULT_CACHEDB,
- DOMAIN,
- SIGNAL_CAPTURE_IMAGE,
- SIGNAL_TRIGGER_QUICK_ACTION,
-)
-
-_LOGGER = logging.getLogger(__name__)
+from .const import ATTRIBUTION, DEFAULT_CACHEDB, DOMAIN, LOGGER
CONF_POLLING = "polling"
SERVICE_SETTINGS = "change_setting"
SERVICE_CAPTURE_IMAGE = "capture_image"
-SERVICE_TRIGGER = "trigger_quick_action"
+SERVICE_TRIGGER_AUTOMATION = "trigger_automation"
ATTR_DEVICE_ID = "device_id"
ATTR_DEVICE_NAME = "device_name"
@@ -53,8 +44,6 @@ ATTR_APP_TYPE = "app_type"
ATTR_EVENT_BY = "event_by"
ATTR_VALUE = "value"
-ABODE_DEVICE_ID_LIST_SCHEMA = vol.Schema([str])
-
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
@@ -74,7 +63,7 @@ CHANGE_SETTING_SCHEMA = vol.Schema(
CAPTURE_IMAGE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
-TRIGGER_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
+AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
ABODE_PLATFORMS = [
"alarm_control_panel",
@@ -93,7 +82,6 @@ class AbodeSystem:
def __init__(self, abode, polling):
"""Initialize the system."""
-
self.abode = abode
self.polling = polling
self.entity_ids = set()
@@ -130,7 +118,7 @@ async def async_setup_entry(hass, config_entry):
hass.data[DOMAIN] = AbodeSystem(abode, polling)
except (AbodeException, ConnectTimeout, HTTPError) as ex:
- _LOGGER.error("Unable to connect to Abode: %s", str(ex))
+ LOGGER.error("Unable to connect to Abode: %s", str(ex))
return False
for platform in ABODE_PLATFORMS:
@@ -149,7 +137,7 @@ async def async_unload_entry(hass, config_entry):
"""Unload a config entry."""
hass.services.async_remove(DOMAIN, SERVICE_SETTINGS)
hass.services.async_remove(DOMAIN, SERVICE_CAPTURE_IMAGE)
- hass.services.async_remove(DOMAIN, SERVICE_TRIGGER)
+ hass.services.async_remove(DOMAIN, SERVICE_TRIGGER_AUTOMATION)
tasks = []
@@ -180,7 +168,7 @@ def setup_hass_services(hass):
try:
hass.data[DOMAIN].abode.set_setting(setting, value)
except AbodeException as ex:
- _LOGGER.warning(ex)
+ LOGGER.warning(ex)
def capture_image(call):
"""Capture a new image."""
@@ -193,11 +181,11 @@ def setup_hass_services(hass):
]
for entity_id in target_entities:
- signal = SIGNAL_CAPTURE_IMAGE.format(entity_id)
+ signal = f"abode_camera_capture_{entity_id}"
dispatcher_send(hass, signal)
- def trigger_quick_action(call):
- """Trigger a quick action."""
+ def trigger_automation(call):
+ """Trigger an Abode automation."""
entity_ids = call.data.get(ATTR_ENTITY_ID, None)
target_entities = [
@@ -207,7 +195,7 @@ def setup_hass_services(hass):
]
for entity_id in target_entities:
- signal = SIGNAL_TRIGGER_QUICK_ACTION.format(entity_id)
+ signal = f"abode_trigger_automation_{entity_id}"
dispatcher_send(hass, signal)
hass.services.register(
@@ -219,7 +207,7 @@ def setup_hass_services(hass):
)
hass.services.register(
- DOMAIN, SERVICE_TRIGGER, trigger_quick_action, schema=TRIGGER_SCHEMA
+ DOMAIN, SERVICE_TRIGGER_AUTOMATION, trigger_automation, schema=AUTOMATION_SCHEMA
)
@@ -232,7 +220,7 @@ async def setup_hass_events(hass):
hass.data[DOMAIN].abode.events.stop()
hass.data[DOMAIN].abode.logout()
- _LOGGER.info("Logged out of Abode")
+ LOGGER.info("Logged out of Abode")
if not hass.data[DOMAIN].polling:
await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.start)
@@ -390,11 +378,14 @@ class AbodeAutomation(Entity):
"""Return the state attributes."""
return {
ATTR_ATTRIBUTION: ATTRIBUTION,
- "automation_id": self._automation.automation_id,
- "type": self._automation.type,
- "sub_type": self._automation.sub_type,
+ "type": "CUE automation",
}
+ @property
+ 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()
diff --git a/homeassistant/components/abode/alarm_control_panel.py b/homeassistant/components/abode/alarm_control_panel.py
index b9a0a8ce192..40040d90d0d 100644
--- a/homeassistant/components/abode/alarm_control_panel.py
+++ b/homeassistant/components/abode/alarm_control_panel.py
@@ -1,6 +1,4 @@
"""Support for Abode Security System alarm control panels."""
-import logging
-
import homeassistant.components.alarm_control_panel as alarm
from homeassistant.components.alarm_control_panel.const import (
SUPPORT_ALARM_ARM_AWAY,
@@ -16,8 +14,6 @@ from homeassistant.const import (
from . import AbodeDevice
from .const import ATTRIBUTION, DOMAIN
-_LOGGER = logging.getLogger(__name__)
-
ICON = "mdi:security"
@@ -50,6 +46,11 @@ class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanel):
state = None
return state
+ @property
+ def code_arm_required(self):
+ """Whether the code is required for arm actions."""
+ return False
+
@property
def supported_features(self) -> int:
"""Return the list of supported features."""
diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py
index c27357ca076..c4cdadf9bd9 100644
--- a/homeassistant/components/abode/binary_sensor.py
+++ b/homeassistant/components/abode/binary_sensor.py
@@ -1,16 +1,10 @@
"""Support for Abode Security System binary sensors."""
-import logging
-
import abodepy.helpers.constants as CONST
-import abodepy.helpers.timeline as TIMELINE
from homeassistant.components.binary_sensor import BinarySensorDevice
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from . import AbodeAutomation, AbodeDevice
-from .const import DOMAIN, SIGNAL_TRIGGER_QUICK_ACTION
-
-_LOGGER = logging.getLogger(__name__)
+from . import AbodeDevice
+from .const import DOMAIN
async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -30,13 +24,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
for device in data.abode.get_devices(generic_type=device_types):
entities.append(AbodeBinarySensor(data, device))
- for automation in data.abode.get_automations(generic_type=CONST.TYPE_QUICK_ACTION):
- entities.append(
- AbodeQuickActionBinarySensor(
- data, automation, TIMELINE.AUTOMATION_EDIT_GROUP
- )
- )
-
async_add_entities(entities)
@@ -52,22 +39,3 @@ class AbodeBinarySensor(AbodeDevice, BinarySensorDevice):
def device_class(self):
"""Return the class of the binary sensor."""
return self._device.generic_type
-
-
-class AbodeQuickActionBinarySensor(AbodeAutomation, BinarySensorDevice):
- """A binary sensor implementation for Abode quick action automations."""
-
- async def async_added_to_hass(self):
- """Subscribe Abode events."""
- await super().async_added_to_hass()
- signal = SIGNAL_TRIGGER_QUICK_ACTION.format(self.entity_id)
- async_dispatcher_connect(self.hass, signal, self.trigger)
-
- def trigger(self):
- """Trigger a quick automation."""
- self._automation.trigger()
-
- @property
- def is_on(self):
- """Return True if the binary sensor is on."""
- return self._automation.is_active
diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py
index 1742a0a5d6c..bee73644890 100644
--- a/homeassistant/components/abode/camera.py
+++ b/homeassistant/components/abode/camera.py
@@ -1,6 +1,5 @@
"""Support for Abode Security System cameras."""
from datetime import timedelta
-import logging
import abodepy.helpers.constants as CONST
import abodepy.helpers.timeline as TIMELINE
@@ -11,12 +10,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util import Throttle
from . import AbodeDevice
-from .const import DOMAIN, SIGNAL_CAPTURE_IMAGE
+from .const import DOMAIN, LOGGER
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
-_LOGGER = logging.getLogger(__name__)
-
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Abode camera devices."""
@@ -50,7 +47,7 @@ class AbodeCamera(AbodeDevice, Camera):
self._capture_callback,
)
- signal = SIGNAL_CAPTURE_IMAGE.format(self.entity_id)
+ signal = f"abode_camera_capture_{self.entity_id}"
async_dispatcher_connect(self.hass, signal, self.capture)
def capture(self):
@@ -71,7 +68,7 @@ class AbodeCamera(AbodeDevice, Camera):
self._response.raise_for_status()
except requests.HTTPError as err:
- _LOGGER.warning("Failed to get camera image: %s", err)
+ LOGGER.warning("Failed to get camera image: %s", err)
self._response = None
else:
self._response = None
diff --git a/homeassistant/components/abode/config_flow.py b/homeassistant/components/abode/config_flow.py
index 89b389798f6..5c2c5e7b843 100644
--- a/homeassistant/components/abode/config_flow.py
+++ b/homeassistant/components/abode/config_flow.py
@@ -1,6 +1,4 @@
"""Config flow for the Abode Security System component."""
-import logging
-
from abodepy import Abode
from abodepy.exceptions import AbodeException
from requests.exceptions import ConnectTimeout, HTTPError
@@ -10,12 +8,10 @@ from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
-from .const import DEFAULT_CACHEDB, DOMAIN # pylint: disable=unused-import
+from .const import DEFAULT_CACHEDB, DOMAIN, LOGGER # pylint: disable=unused-import
CONF_POLLING = "polling"
-_LOGGER = logging.getLogger(__name__)
-
class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for Abode."""
@@ -32,7 +28,6 @@ class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
-
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
@@ -50,7 +45,7 @@ class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
)
except (AbodeException, ConnectTimeout, HTTPError) as ex:
- _LOGGER.error("Unable to connect to Abode: %s", str(ex))
+ LOGGER.error("Unable to connect to Abode: %s", str(ex))
if ex.errcode == 400:
return self._show_form({"base": "invalid_credentials"})
return self._show_form({"base": "connection_error"})
@@ -76,7 +71,7 @@ class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_import(self, import_config):
"""Import a config entry from configuration.yaml."""
if self._async_current_entries():
- _LOGGER.warning("Only one configuration of abode is allowed.")
+ LOGGER.warning("Only one configuration of abode is allowed.")
return self.async_abort(reason="single_instance_allowed")
return await self.async_step_user(import_config)
diff --git a/homeassistant/components/abode/const.py b/homeassistant/components/abode/const.py
index 267eb04f72e..b509984876b 100644
--- a/homeassistant/components/abode/const.py
+++ b/homeassistant/components/abode/const.py
@@ -1,8 +1,9 @@
"""Constants for the Abode Security System component."""
+import logging
+
+LOGGER = logging.getLogger(__package__)
+
DOMAIN = "abode"
ATTRIBUTION = "Data provided by goabode.com"
DEFAULT_CACHEDB = "abodepy_cache.pickle"
-
-SIGNAL_CAPTURE_IMAGE = "abode_camera_capture_{}"
-SIGNAL_TRIGGER_QUICK_ACTION = "abode_trigger_quick_action_{}"
diff --git a/homeassistant/components/abode/cover.py b/homeassistant/components/abode/cover.py
index ec4f54a985c..6e38c11cfcc 100644
--- a/homeassistant/components/abode/cover.py
+++ b/homeassistant/components/abode/cover.py
@@ -1,6 +1,4 @@
"""Support for Abode Security System covers."""
-import logging
-
import abodepy.helpers.constants as CONST
from homeassistant.components.cover import CoverDevice
@@ -8,8 +6,6 @@ from homeassistant.components.cover import CoverDevice
from . import AbodeDevice
from .const import DOMAIN
-_LOGGER = logging.getLogger(__name__)
-
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Abode cover devices."""
diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py
index ad2df23ef9c..f15c10fc410 100644
--- a/homeassistant/components/abode/light.py
+++ b/homeassistant/components/abode/light.py
@@ -1,5 +1,4 @@
"""Support for Abode Security System lights."""
-import logging
from math import ceil
import abodepy.helpers.constants as CONST
@@ -21,8 +20,6 @@ from homeassistant.util.color import (
from . import AbodeDevice
from .const import DOMAIN
-_LOGGER = logging.getLogger(__name__)
-
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Abode light devices."""
@@ -45,16 +42,19 @@ class AbodeLight(AbodeDevice, Light):
self._device.set_color_temp(
int(color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]))
)
+ return
if ATTR_HS_COLOR in kwargs and self._device.is_color_capable:
self._device.set_color(kwargs[ATTR_HS_COLOR])
+ return
if ATTR_BRIGHTNESS in kwargs and self._device.is_dimmable:
# Convert Home Assistant brightness (0-255) to Abode brightness (0-99)
# If 100 is sent to Abode, response is 99 causing an error
self._device.set_level(ceil(kwargs[ATTR_BRIGHTNESS] * 99 / 255.0))
- else:
- self._device.switch_on()
+ return
+
+ self._device.switch_on()
def turn_off(self, **kwargs):
"""Turn off the light."""
diff --git a/homeassistant/components/abode/lock.py b/homeassistant/components/abode/lock.py
index b05a3e7f297..33431433ef9 100644
--- a/homeassistant/components/abode/lock.py
+++ b/homeassistant/components/abode/lock.py
@@ -1,6 +1,4 @@
"""Support for the Abode Security System locks."""
-import logging
-
import abodepy.helpers.constants as CONST
from homeassistant.components.lock import LockDevice
@@ -8,8 +6,6 @@ from homeassistant.components.lock import LockDevice
from . import AbodeDevice
from .const import DOMAIN
-_LOGGER = logging.getLogger(__name__)
-
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Abode lock devices."""
diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json
index 383320141e5..eabd4a7f74f 100644
--- a/homeassistant/components/abode/manifest.json
+++ b/homeassistant/components/abode/manifest.json
@@ -3,7 +3,7 @@
"name": "Abode",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/abode",
- "requirements": ["abodepy==0.17.0"],
+ "requirements": ["abodepy==0.18.1"],
"dependencies": [],
"codeowners": ["@shred86"]
}
diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py
index dc622cb1a38..6ecc5c871cd 100644
--- a/homeassistant/components/abode/sensor.py
+++ b/homeassistant/components/abode/sensor.py
@@ -1,6 +1,4 @@
"""Support for Abode Security System sensors."""
-import logging
-
import abodepy.helpers.constants as CONST
from homeassistant.const import (
@@ -12,8 +10,6 @@ from homeassistant.const import (
from . import AbodeDevice
from .const import DOMAIN
-_LOGGER = logging.getLogger(__name__)
-
# Sensor types: Name, icon
SENSOR_TYPES = {
CONST.TEMP_STATUS_KEY: ["Temperature", DEVICE_CLASS_TEMPERATURE],
@@ -44,9 +40,7 @@ class AbodeSensor(AbodeDevice):
"""Initialize a sensor for an Abode device."""
super().__init__(data, device)
self._sensor_type = sensor_type
- self._name = "{0} {1}".format(
- self._device.name, SENSOR_TYPES[self._sensor_type][0]
- )
+ self._name = f"{self._device.name} {SENSOR_TYPES[self._sensor_type][0]}"
self._device_class = SENSOR_TYPES[self._sensor_type][1]
@property
diff --git a/homeassistant/components/abode/services.yaml b/homeassistant/components/abode/services.yaml
index ad0bb076d90..5818cdc0048 100644
--- a/homeassistant/components/abode/services.yaml
+++ b/homeassistant/components/abode/services.yaml
@@ -7,7 +7,7 @@ change_setting:
fields:
setting: {description: Setting to change., example: beeper_mute}
value: {description: Value of the setting., example: '1'}
-trigger_quick_action:
- description: Trigger an Abode quick action.
+trigger_automation:
+ description: Trigger an Abode automation.
fields:
- entity_id: {description: Entity id of the quick action to trigger., example: binary_sensor.home_quick_action}
+ entity_id: {description: Entity id of the automation to trigger., example: switch.my_automation}
\ No newline at end of file
diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py
index d6773e10ca1..e29deb72f82 100644
--- a/homeassistant/components/abode/switch.py
+++ b/homeassistant/components/abode/switch.py
@@ -1,18 +1,17 @@
"""Support for Abode Security System switches."""
-import logging
-
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
from . import AbodeAutomation, AbodeDevice
from .const import DOMAIN
-_LOGGER = logging.getLogger(__name__)
-
DEVICE_TYPES = [CONST.TYPE_SWITCH, CONST.TYPE_VALVE]
+ICON = "mdi:robot"
+
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Abode switch devices."""
@@ -24,7 +23,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
for device in data.abode.get_devices(generic_type=device_type):
entities.append(AbodeSwitch(data, device))
- for automation in data.abode.get_automations(generic_type=CONST.TYPE_AUTOMATION):
+ for automation in data.abode.get_automations():
entities.append(
AbodeAutomationSwitch(data, automation, TIMELINE.AUTOMATION_EDIT_GROUP)
)
@@ -52,15 +51,33 @@ class AbodeSwitch(AbodeDevice, SwitchDevice):
class AbodeAutomationSwitch(AbodeAutomation, SwitchDevice):
"""A switch implementation for Abode automations."""
+ async def async_added_to_hass(self):
+ """Subscribe Abode events."""
+ await super().async_added_to_hass()
+
+ signal = f"abode_trigger_automation_{self.entity_id}"
+ async_dispatcher_connect(self.hass, signal, self.trigger)
+
def turn_on(self, **kwargs):
- """Turn on the device."""
- self._automation.set_active(True)
+ """Enable the automation."""
+ if self._automation.enable(True):
+ self.schedule_update_ha_state()
def turn_off(self, **kwargs):
- """Turn off the device."""
- self._automation.set_active(False)
+ """Disable the automation."""
+ if self._automation.enable(False):
+ self.schedule_update_ha_state()
+
+ def trigger(self):
+ """Trigger the automation."""
+ self._automation.trigger()
@property
def is_on(self):
- """Return True if the binary sensor is on."""
- return self._automation.is_active
+ """Return True if the automation is enabled."""
+ return self._automation.is_enabled
+
+ @property
+ def icon(self):
+ """Return the robot icon to match Home Assistant automations."""
+ return ICON
diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py
index e5618282a97..5abff10739a 100644
--- a/homeassistant/components/adguard/sensor.py
+++ b/homeassistant/components/adguard/sensor.py
@@ -11,6 +11,7 @@ from homeassistant.components.adguard.const import (
DOMAIN,
)
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import TIME_MILLISECONDS, UNIT_PERCENTAGE
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.typing import HomeAssistantType
@@ -133,7 +134,7 @@ class AdGuardHomePercentageBlockedSensor(AdGuardHomeSensor):
"AdGuard DNS Queries Blocked Ratio",
"mdi:magnify-close",
"blocked_percentage",
- "%",
+ UNIT_PERCENTAGE,
)
async def _adguard_update(self) -> None:
@@ -206,7 +207,7 @@ class AdGuardHomeAverageProcessingTimeSensor(AdGuardHomeSensor):
"AdGuard Average Processing Speed",
"mdi:speedometer",
"average_speed",
- "ms",
+ TIME_MILLISECONDS,
)
async def _adguard_update(self) -> None:
diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py
index ab83f711153..a6754b4a00d 100644
--- a/homeassistant/components/airly/sensor.py
+++ b/homeassistant/components/airly/sensor.py
@@ -2,12 +2,14 @@
from homeassistant.const import (
ATTR_ATTRIBUTION,
ATTR_DEVICE_CLASS,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONF_NAME,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
PRESSURE_HPA,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers.entity import Entity
@@ -26,21 +28,18 @@ ATTR_ICON = "icon"
ATTR_LABEL = "label"
ATTR_UNIT = "unit"
-HUMI_PERCENT = "%"
-VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m³"
-
SENSOR_TYPES = {
ATTR_API_PM1: {
ATTR_DEVICE_CLASS: None,
ATTR_ICON: "mdi:blur",
ATTR_LABEL: ATTR_API_PM1,
- ATTR_UNIT: VOLUME_MICROGRAMS_PER_CUBIC_METER,
+ ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
ATTR_API_HUMIDITY: {
ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
ATTR_ICON: None,
ATTR_LABEL: ATTR_API_HUMIDITY.capitalize(),
- ATTR_UNIT: HUMI_PERCENT,
+ ATTR_UNIT: UNIT_PERCENTAGE,
},
ATTR_API_PRESSURE: {
ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE,
diff --git a/homeassistant/components/airvisual/.translations/ca.json b/homeassistant/components/airvisual/.translations/ca.json
new file mode 100644
index 00000000000..b80386dc75b
--- /dev/null
+++ b/homeassistant/components/airvisual/.translations/ca.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Aquesta clau API ja est\u00e0 sent utilitzada."
+ },
+ "error": {
+ "invalid_api_key": "Clau API inv\u00e0lida"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Clau API",
+ "latitude": "Latitud",
+ "longitude": "Longitud",
+ "show_on_map": "Mostra al mapa l'\u00e0rea geogr\u00e0fica monitoritzada"
+ },
+ "description": "Monitoritzaci\u00f3 de la qualitat de l'aire per ubicaci\u00f3 geogr\u00e0fica.",
+ "title": "Configura AirVisual"
+ }
+ },
+ "title": "AirVisual"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/de.json b/homeassistant/components/airvisual/.translations/de.json
new file mode 100644
index 00000000000..0c624614610
--- /dev/null
+++ b/homeassistant/components/airvisual/.translations/de.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Dieser API-Schl\u00fcssel wird bereits verwendet."
+ },
+ "error": {
+ "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API-Schl\u00fcssel",
+ "latitude": "Breitengrad",
+ "longitude": "L\u00e4ngengrad"
+ },
+ "title": "Konfigurieren Sie AirVisual"
+ }
+ },
+ "title": "AirVisual"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/en.json b/homeassistant/components/airvisual/.translations/en.json
new file mode 100644
index 00000000000..2bcff29b770
--- /dev/null
+++ b/homeassistant/components/airvisual/.translations/en.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "This API key is already in use."
+ },
+ "error": {
+ "invalid_api_key": "Invalid API key"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API Key",
+ "latitude": "Latitude",
+ "longitude": "Longitude"
+ },
+ "description": "Monitor air quality in a geographical location.",
+ "title": "Configure AirVisual"
+ }
+ },
+ "title": "AirVisual"
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "show_on_map": "Show monitored geography on the map"
+ },
+ "description": "Set various options for the AirVisual integration.",
+ "title": "Configure AirVisual"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/es.json b/homeassistant/components/airvisual/.translations/es.json
new file mode 100644
index 00000000000..3ec5c12f1e9
--- /dev/null
+++ b/homeassistant/components/airvisual/.translations/es.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Esta clave API ya est\u00e1 en uso."
+ },
+ "error": {
+ "invalid_api_key": "Clave API inv\u00e1lida"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Clave API",
+ "latitude": "Latitud",
+ "longitude": "Longitud",
+ "show_on_map": "Mostrar geograf\u00eda monitorizada en el mapa"
+ },
+ "description": "Monitorizar la calidad del aire en una ubicaci\u00f3n geogr\u00e1fica.",
+ "title": "Configurar AirVisual"
+ }
+ },
+ "title": "AirVisual"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/it.json b/homeassistant/components/airvisual/.translations/it.json
new file mode 100644
index 00000000000..860a1e3e577
--- /dev/null
+++ b/homeassistant/components/airvisual/.translations/it.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Questa chiave API \u00e8 gi\u00e0 in uso."
+ },
+ "error": {
+ "invalid_api_key": "Chiave API non valida"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Chiave API",
+ "latitude": "Latitudine",
+ "longitude": "Logitudine",
+ "show_on_map": "Mostra l'area geografica monitorata sulla mappa"
+ },
+ "description": "Monitorare la qualit\u00e0 dell'aria in una posizione geografica.",
+ "title": "Configura AirVisual"
+ }
+ },
+ "title": "AirVisual"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/lb.json b/homeassistant/components/airvisual/.translations/lb.json
new file mode 100644
index 00000000000..0ae807dde52
--- /dev/null
+++ b/homeassistant/components/airvisual/.translations/lb.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "D\u00ebsen App Schl\u00ebssel g\u00ebtt scho benotzt"
+ },
+ "error": {
+ "invalid_api_key": "Ong\u00ebltegen API Schl\u00ebssel"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API Schl\u00ebssel",
+ "latitude": "Breedegrad",
+ "longitude": "L\u00e4ngegrad"
+ },
+ "title": "AirVisual konfigur\u00e9ieren"
+ }
+ },
+ "title": "AirVisual"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/no.json b/homeassistant/components/airvisual/.translations/no.json
new file mode 100644
index 00000000000..bf089c485d6
--- /dev/null
+++ b/homeassistant/components/airvisual/.translations/no.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Denne API-n\u00f8kkelen er allerede i bruk."
+ },
+ "error": {
+ "invalid_api_key": "Ugyldig API-n\u00f8kkel"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API-n\u00f8kkel",
+ "latitude": "Breddegrad",
+ "longitude": "Lengdegrad",
+ "show_on_map": "Vis overv\u00e5ket geografi p\u00e5 kartet"
+ },
+ "description": "Overv\u00e5k luftkvaliteten p\u00e5 et geografisk sted.",
+ "title": "Konfigurer AirVisual"
+ }
+ },
+ "title": "AirVisual"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/ru.json b/homeassistant/components/airvisual/.translations/ru.json
new file mode 100644
index 00000000000..2eac29c9ecc
--- /dev/null
+++ b/homeassistant/components/airvisual/.translations/ru.json
@@ -0,0 +1,23 @@
+{
+ "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."
+ },
+ "error": {
+ "invalid_api_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "\u041a\u043b\u044e\u0447 API",
+ "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430",
+ "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430",
+ "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": "\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"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/.translations/zh-Hant.json b/homeassistant/components/airvisual/.translations/zh-Hant.json
new file mode 100644
index 00000000000..3f62c06a9e2
--- /dev/null
+++ b/homeassistant/components/airvisual/.translations/zh-Hant.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u6b64 API \u5bc6\u9470\u5df2\u88ab\u4f7f\u7528\u3002"
+ },
+ "error": {
+ "invalid_api_key": "API \u5bc6\u78bc\u7121\u6548"
+ },
+ "step": {
+ "user": {
+ "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"
+ },
+ "description": "\u4f9d\u5730\u7406\u4f4d\u7f6e\u76e3\u63a7\u7a7a\u6c23\u54c1\u8cea\u3002",
+ "title": "\u8a2d\u5b9a AirVisual"
+ }
+ },
+ "title": "AirVisual"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py
index b1f79d17241..a48acf7bb34 100644
--- a/homeassistant/components/airvisual/__init__.py
+++ b/homeassistant/components/airvisual/__init__.py
@@ -1 +1,215 @@
"""The airvisual component."""
+import asyncio
+import logging
+
+from pyairvisual import Client
+from pyairvisual.errors import AirVisualError, InvalidKeyError
+import voluptuous as vol
+
+from homeassistant.config_entries import SOURCE_IMPORT
+from homeassistant.const import (
+ CONF_API_KEY,
+ CONF_LATITUDE,
+ CONF_LONGITUDE,
+ CONF_SHOW_ON_MAP,
+ CONF_STATE,
+)
+from homeassistant.core import callback
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import aiohttp_client, config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.event import async_track_time_interval
+
+from .const import (
+ CONF_CITY,
+ CONF_COUNTRY,
+ CONF_GEOGRAPHIES,
+ DATA_CLIENT,
+ DEFAULT_SCAN_INTERVAL,
+ DOMAIN,
+ TOPIC_UPDATE,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_LISTENER = "listener"
+
+DEFAULT_OPTIONS = {CONF_SHOW_ON_MAP: True}
+
+CONF_NODE_ID = "node_id"
+
+GEOGRAPHY_COORDINATES_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_LATITUDE): cv.latitude,
+ vol.Required(CONF_LONGITUDE): cv.longitude,
+ }
+)
+
+GEOGRAPHY_PLACE_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_CITY): cv.string,
+ vol.Required(CONF_STATE): cv.string,
+ vol.Required(CONF_COUNTRY): cv.string,
+ }
+)
+
+CLOUD_API_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_GEOGRAPHIES, default=[]): vol.All(
+ cv.ensure_list,
+ [vol.Any(GEOGRAPHY_COORDINATES_SCHEMA, GEOGRAPHY_PLACE_SCHEMA)],
+ ),
+ }
+)
+
+CONFIG_SCHEMA = vol.Schema({DOMAIN: CLOUD_API_SCHEMA}, extra=vol.ALLOW_EXTRA)
+
+
+@callback
+def async_get_geography_id(geography_dict):
+ """Generate a unique ID from a geography dict."""
+ if CONF_CITY in geography_dict:
+ return ",".join(
+ (
+ geography_dict[CONF_CITY],
+ geography_dict[CONF_STATE],
+ geography_dict[CONF_COUNTRY],
+ )
+ )
+ 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] = {}
+
+ 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
+ )
+ )
+
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Set up AirVisual as config entry."""
+ entry_updates = {}
+ if not config_entry.unique_id:
+ # If the config entry doesn't already have a unique ID, set one:
+ entry_updates["unique_id"] = config_entry.data[CONF_API_KEY]
+ if not config_entry.options:
+ # If the config entry doesn't already have any options set, set defaults:
+ entry_updates["options"] = DEFAULT_OPTIONS
+
+ if entry_updates:
+ hass.config_entries.async_update_entry(config_entry, **entry_updates)
+
+ websession = aiohttp_client.async_get_clientsession(hass)
+
+ hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = AirVisualData(
+ hass, Client(websession, api_key=config_entry.data[CONF_API_KEY]), config_entry
+ )
+
+ try:
+ await hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id].async_update()
+ except InvalidKeyError:
+ _LOGGER.error("Invalid API key provided")
+ raise ConfigEntryNotReady
+
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(config_entry, "sensor")
+ )
+
+ async def refresh(event_time):
+ """Refresh data from AirVisual."""
+ await hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id].async_update()
+
+ hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = async_track_time_interval(
+ hass, refresh, DEFAULT_SCAN_INTERVAL
+ )
+
+ config_entry.add_update_listener(async_update_options)
+
+ 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)
+
+ remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(config_entry.entry_id)
+ remove_listener()
+
+ await hass.config_entries.async_forward_entry_unload(config_entry, "sensor")
+
+ return True
+
+
+async def async_update_options(hass, config_entry):
+ """Handle an options update."""
+ airvisual = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id]
+ airvisual.async_update_options(config_entry.options)
+
+
+class AirVisualData:
+ """Define a class to manage data from the AirVisual cloud API."""
+
+ def __init__(self, hass, client, config_entry):
+ """Initialize."""
+ self._client = client
+ self._hass = hass
+ self.data = {}
+ 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 = []
+
+ 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
+
+ _LOGGER.debug("Received new data")
+ async_dispatcher_send(self._hass, TOPIC_UPDATE)
+
+ @callback
+ def async_update_options(self, options):
+ """Update the data manager's options."""
+ self.options = options
+ async_dispatcher_send(self._hass, TOPIC_UPDATE)
diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py
new file mode 100644
index 00000000000..2f961ccfb49
--- /dev/null
+++ b/homeassistant/components/airvisual/config_flow.py
@@ -0,0 +1,123 @@
+"""Define a config flow manager for AirVisual."""
+import logging
+
+from pyairvisual import Client
+from pyairvisual.errors import InvalidKeyError
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import (
+ CONF_API_KEY,
+ CONF_LATITUDE,
+ CONF_LONGITUDE,
+ CONF_SHOW_ON_MAP,
+)
+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")
+
+
+class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle an AirVisual config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ @property
+ def cloud_api_schema(self):
+ """Return the data schema for the cloud API."""
+ return vol.Schema(
+ {
+ vol.Required(CONF_API_KEY): str,
+ vol.Required(
+ CONF_LATITUDE, default=self.hass.config.latitude
+ ): cv.latitude,
+ vol.Required(
+ CONF_LONGITUDE, default=self.hass.config.longitude
+ ): cv.longitude,
+ }
+ )
+
+ async def _async_set_unique_id(self, unique_id):
+ """Set the unique ID of the config flow and abort if it already exists."""
+ await self.async_set_unique_id(unique_id)
+ self._abort_if_unique_id_configured()
+
+ @callback
+ async def _show_form(self, errors=None):
+ """Show the form to the user."""
+ return self.async_show_form(
+ step_id="user", data_schema=self.cloud_api_schema, errors=errors or {},
+ )
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(config_entry):
+ """Define the config flow to handle options."""
+ return AirVisualOptionsFlowHandler(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)
+
+ async def async_step_user(self, user_input=None):
+ """Handle the start of the config flow."""
+ if not user_input:
+ return await self._show_form()
+
+ await self._async_set_unique_id(user_input[CONF_API_KEY])
+
+ 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
+ )
+
+
+class AirVisualOptionsFlowHandler(config_entries.OptionsFlow):
+ """Handle an AirVisual 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.Required(
+ CONF_SHOW_ON_MAP,
+ default=self.config_entry.options.get(CONF_SHOW_ON_MAP),
+ ): bool
+ }
+ ),
+ )
diff --git a/homeassistant/components/airvisual/const.py b/homeassistant/components/airvisual/const.py
new file mode 100644
index 00000000000..ab54e191116
--- /dev/null
+++ b/homeassistant/components/airvisual/const.py
@@ -0,0 +1,14 @@
+"""Define AirVisual constants."""
+from datetime import timedelta
+
+DOMAIN = "airvisual"
+
+CONF_CITY = "city"
+CONF_COUNTRY = "country"
+CONF_GEOGRAPHIES = "geographies"
+
+DATA_CLIENT = "client"
+
+DEFAULT_SCAN_INTERVAL = timedelta(minutes=10)
+
+TOPIC_UPDATE = f"{DOMAIN}_update"
diff --git a/homeassistant/components/airvisual/manifest.json b/homeassistant/components/airvisual/manifest.json
index a689ee6acf0..756fb56acc1 100644
--- a/homeassistant/components/airvisual/manifest.json
+++ b/homeassistant/components/airvisual/manifest.json
@@ -1,6 +1,7 @@
{
"domain": "airvisual",
"name": "AirVisual",
+ "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airvisual",
"requirements": ["pyairvisual==3.0.1"],
"dependencies": [],
diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py
index 3b177c4ce67..28d2b3f5f86 100644
--- a/homeassistant/components/airvisual/sensor.py
+++ b/homeassistant/components/airvisual/sensor.py
@@ -1,27 +1,24 @@
"""Support for AirVisual air quality sensors."""
-from datetime import timedelta
from logging import getLogger
-from pyairvisual import Client
-from pyairvisual.errors import AirVisualError
-import voluptuous as vol
-
-from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import (
ATTR_ATTRIBUTION,
ATTR_LATITUDE,
ATTR_LONGITUDE,
- CONF_API_KEY,
+ ATTR_STATE,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_PARTS_PER_MILLION,
CONF_LATITUDE,
CONF_LONGITUDE,
- CONF_MONITORED_CONDITIONS,
- CONF_SCAN_INTERVAL,
CONF_SHOW_ON_MAP,
CONF_STATE,
)
-from homeassistant.helpers import aiohttp_client, config_validation as cv
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
-from homeassistant.util import Throttle
+
+from .const import CONF_CITY, CONF_COUNTRY, DATA_CLIENT, DOMAIN, TOPIC_UPDATE
_LOGGER = getLogger(__name__)
@@ -31,23 +28,19 @@ ATTR_POLLUTANT_SYMBOL = "pollutant_symbol"
ATTR_POLLUTANT_UNIT = "pollutant_unit"
ATTR_REGION = "region"
-CONF_CITY = "city"
-CONF_COUNTRY = "country"
-
DEFAULT_ATTRIBUTION = "Data provided by AirVisual"
-DEFAULT_SCAN_INTERVAL = timedelta(minutes=10)
MASS_PARTS_PER_MILLION = "ppm"
MASS_PARTS_PER_BILLION = "ppb"
VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m3"
-SENSOR_TYPE_LEVEL = "air_pollution_level"
-SENSOR_TYPE_AQI = "air_quality_index"
-SENSOR_TYPE_POLLUTANT = "main_pollutant"
+SENSOR_KIND_LEVEL = "air_pollution_level"
+SENSOR_KIND_AQI = "air_quality_index"
+SENSOR_KIND_POLLUTANT = "main_pollutant"
SENSORS = [
- (SENSOR_TYPE_LEVEL, "Air Pollution Level", "mdi:gauge", None),
- (SENSOR_TYPE_AQI, "Air Quality Index", "mdi:chart-line", "AQI"),
- (SENSOR_TYPE_POLLUTANT, "Main Pollutant", "mdi:chemical-weapon", None),
+ (SENSOR_KIND_LEVEL, "Air Pollution Level", "mdi:gauge", None),
+ (SENSOR_KIND_AQI, "Air Quality Index", "mdi:chart-line", "AQI"),
+ (SENSOR_KIND_POLLUTANT, "Main Pollutant", "mdi:chemical-weapon", None),
]
POLLUTANT_LEVEL_MAPPING = [
@@ -70,112 +63,68 @@ POLLUTANT_LEVEL_MAPPING = [
]
POLLUTANT_MAPPING = {
- "co": {"label": "Carbon Monoxide", "unit": MASS_PARTS_PER_MILLION},
- "n2": {"label": "Nitrogen Dioxide", "unit": MASS_PARTS_PER_BILLION},
- "o3": {"label": "Ozone", "unit": MASS_PARTS_PER_BILLION},
- "p1": {"label": "PM10", "unit": VOLUME_MICROGRAMS_PER_CUBIC_METER},
- "p2": {"label": "PM2.5", "unit": VOLUME_MICROGRAMS_PER_CUBIC_METER},
- "s2": {"label": "Sulfur Dioxide", "unit": MASS_PARTS_PER_BILLION},
+ "co": {"label": "Carbon Monoxide", "unit": CONCENTRATION_PARTS_PER_MILLION},
+ "n2": {"label": "Nitrogen Dioxide", "unit": CONCENTRATION_PARTS_PER_BILLION},
+ "o3": {"label": "Ozone", "unit": CONCENTRATION_PARTS_PER_BILLION},
+ "p1": {"label": "PM10", "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
+ "p2": {"label": "PM2.5", "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
+ "s2": {"label": "Sulfur Dioxide", "unit": CONCENTRATION_PARTS_PER_BILLION},
}
SENSOR_LOCALES = {"cn": "Chinese", "us": "U.S."}
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_API_KEY): cv.string,
- vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_LOCALES)): vol.All(
- cv.ensure_list, [vol.In(SENSOR_LOCALES)]
- ),
- vol.Inclusive(CONF_CITY, "city"): cv.string,
- vol.Inclusive(CONF_COUNTRY, "city"): cv.string,
- vol.Inclusive(CONF_LATITUDE, "coords"): cv.latitude,
- vol.Inclusive(CONF_LONGITUDE, "coords"): cv.longitude,
- vol.Optional(CONF_SHOW_ON_MAP, default=True): cv.boolean,
- vol.Inclusive(CONF_STATE, "city"): cv.string,
- vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period,
- }
-)
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up AirVisual sensors based on a config entry."""
+ airvisual = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id]
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
- """Configure the platform and add the sensors."""
-
- city = config.get(CONF_CITY)
- state = config.get(CONF_STATE)
- country = config.get(CONF_COUNTRY)
-
- latitude = config.get(CONF_LATITUDE, hass.config.latitude)
- longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
-
- websession = aiohttp_client.async_get_clientsession(hass)
-
- if city and state and country:
- _LOGGER.debug(
- "Using city, state, and country: %s, %s, %s", city, state, country
- )
- location_id = ",".join((city, state, country))
- data = AirVisualData(
- Client(websession, api_key=config[CONF_API_KEY]),
- city=city,
- state=state,
- country=country,
- show_on_map=config[CONF_SHOW_ON_MAP],
- scan_interval=config[CONF_SCAN_INTERVAL],
- )
- else:
- _LOGGER.debug("Using latitude and longitude: %s, %s", latitude, longitude)
- location_id = ",".join((str(latitude), str(longitude)))
- data = AirVisualData(
- Client(websession, api_key=config[CONF_API_KEY]),
- latitude=latitude,
- longitude=longitude,
- show_on_map=config[CONF_SHOW_ON_MAP],
- scan_interval=config[CONF_SCAN_INTERVAL],
- )
-
- await data.async_update()
-
- sensors = []
- for locale in config[CONF_MONITORED_CONDITIONS]:
- for kind, name, icon, unit in SENSORS:
- sensors.append(
- AirVisualSensor(data, kind, name, icon, unit, locale, location_id)
- )
-
- async_add_entities(sensors, True)
+ async_add_entities(
+ [
+ AirVisualSensor(airvisual, kind, name, icon, unit, locale, geography_id)
+ for geography_id in airvisual.data
+ for locale in SENSOR_LOCALES
+ for kind, name, icon, unit in SENSORS
+ ],
+ True,
+ )
class AirVisualSensor(Entity):
"""Define an AirVisual sensor."""
- def __init__(self, airvisual, kind, name, icon, unit, locale, location_id):
+ def __init__(self, airvisual, kind, name, icon, unit, locale, geography_id):
"""Initialize."""
- self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}
+ self._airvisual = airvisual
+ self._async_unsub_dispatcher_connects = []
+ self._geography_id = geography_id
self._icon = icon
+ self._kind = kind
self._locale = locale
- self._location_id = location_id
self._name = name
self._state = None
- self._type = kind
self._unit = unit
- self.airvisual = airvisual
- @property
- def device_state_attributes(self):
- """Return the device state attributes."""
- if self.airvisual.show_on_map:
- self._attrs[ATTR_LATITUDE] = self.airvisual.latitude
- self._attrs[ATTR_LONGITUDE] = self.airvisual.longitude
- else:
- self._attrs["lati"] = self.airvisual.latitude
- self._attrs["long"] = self.airvisual.longitude
-
- return self._attrs
+ self._attrs = {
+ ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION,
+ ATTR_CITY: airvisual.data[geography_id].get(CONF_CITY),
+ ATTR_STATE: airvisual.data[geography_id].get(CONF_STATE),
+ ATTR_COUNTRY: airvisual.data[geography_id].get(CONF_COUNTRY),
+ }
@property
def available(self):
"""Return True if entity is available."""
- return bool(self.airvisual.pollution_info)
+ try:
+ return bool(
+ self._airvisual.data[self._geography_id]["current"]["pollution"]
+ )
+ except KeyError:
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ return self._attrs
@property
def icon(self):
@@ -185,7 +134,7 @@ class AirVisualSensor(Entity):
@property
def name(self):
"""Return the name."""
- return "{0} {1}".format(SENSOR_LOCALES[self._locale], self._name)
+ return f"{SENSOR_LOCALES[self._locale]} {self._name}"
@property
def state(self):
@@ -195,22 +144,33 @@ class AirVisualSensor(Entity):
@property
def unique_id(self):
"""Return a unique, Home Assistant friendly identifier for this entity."""
- return f"{self._location_id}_{self._locale}_{self._type}"
+ return f"{self._geography_id}_{self._locale}_{self._kind}"
@property
def unit_of_measurement(self):
"""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_connects.append(
+ async_dispatcher_connect(self.hass, TOPIC_UPDATE, update)
+ )
+
async def async_update(self):
"""Update the sensor."""
- await self.airvisual.async_update()
- data = self.airvisual.pollution_info
-
- if not data:
+ try:
+ data = self._airvisual.data[self._geography_id]["current"]["pollution"]
+ except KeyError:
return
- if self._type == SENSOR_TYPE_LEVEL:
+ if self._kind == SENSOR_KIND_LEVEL:
aqi = data[f"aqi{self._locale}"]
[level] = [
i
@@ -219,9 +179,9 @@ class AirVisualSensor(Entity):
]
self._state = level["label"]
self._icon = level["icon"]
- elif self._type == SENSOR_TYPE_AQI:
+ elif self._kind == SENSOR_KIND_AQI:
self._state = data[f"aqi{self._locale}"]
- elif self._type == SENSOR_TYPE_POLLUTANT:
+ elif self._kind == SENSOR_KIND_POLLUTANT:
symbol = data[f"main{self._locale}"]
self._state = POLLUTANT_MAPPING[symbol]["label"]
self._attrs.update(
@@ -231,43 +191,21 @@ class AirVisualSensor(Entity):
}
)
-
-class AirVisualData:
- """Define an object to hold sensor data."""
-
- def __init__(self, client, **kwargs):
- """Initialize."""
- self._client = client
- self.city = kwargs.get(CONF_CITY)
- self.country = kwargs.get(CONF_COUNTRY)
- self.latitude = kwargs.get(CONF_LATITUDE)
- self.longitude = kwargs.get(CONF_LONGITUDE)
- self.pollution_info = {}
- self.show_on_map = kwargs.get(CONF_SHOW_ON_MAP)
- self.state = kwargs.get(CONF_STATE)
-
- self.async_update = Throttle(kwargs[CONF_SCAN_INTERVAL])(self._async_update)
-
- async def _async_update(self):
- """Update AirVisual data."""
-
- try:
- if self.city and self.state and self.country:
- resp = await self._client.api.city(self.city, self.state, self.country)
- self.longitude, self.latitude = resp["location"]["coordinates"]
+ geography = self._airvisual.geographies[self._geography_id]
+ if CONF_LATITUDE in geography:
+ if self._airvisual.options[CONF_SHOW_ON_MAP]:
+ self._attrs[ATTR_LATITUDE] = geography[CONF_LATITUDE]
+ self._attrs[ATTR_LONGITUDE] = geography[CONF_LONGITUDE]
+ self._attrs.pop("lati", None)
+ self._attrs.pop("long", None)
else:
- resp = await self._client.api.nearest_city(
- self.latitude, self.longitude
- )
+ self._attrs["lati"] = geography[CONF_LATITUDE]
+ self._attrs["long"] = geography[CONF_LONGITUDE]
+ self._attrs.pop(ATTR_LATITUDE, None)
+ self._attrs.pop(ATTR_LONGITUDE, None)
- _LOGGER.debug("New data retrieved: %s", resp)
-
- self.pollution_info = resp["current"]["pollution"]
- except (KeyError, AirVisualError) as err:
- if self.city and self.state and self.country:
- location = (self.city, self.state, self.country)
- else:
- location = (self.latitude, self.longitude)
-
- _LOGGER.error("Can't retrieve data for location: %s (%s)", location, err)
- self.pollution_info = {}
+ async def async_will_remove_from_hass(self) -> None:
+ """Disconnect dispatcher listener when removed."""
+ for cancel in self._async_unsub_dispatcher_connects:
+ cancel()
+ self._async_unsub_dispatcher_connects = []
diff --git a/homeassistant/components/airvisual/strings.json b/homeassistant/components/airvisual/strings.json
new file mode 100644
index 00000000000..6e94c393da6
--- /dev/null
+++ b/homeassistant/components/airvisual/strings.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "title": "AirVisual",
+ "step": {
+ "user": {
+ "title": "Configure AirVisual",
+ "description": "Monitor air quality in a geographical location.",
+ "data": {
+ "api_key": "API Key",
+ "latitude": "Latitude",
+ "longitude": "Longitude"
+ }
+ }
+ },
+ "error": {
+ "invalid_api_key": "Invalid API key"
+ },
+ "abort": {
+ "already_configured": "This API key is already in use."
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "title": "Configure AirVisual",
+ "description": "Set various options for the AirVisual integration.",
+ "data": {
+ "show_on_map": "Show monitored geography on the map"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py
index 4cfcd5403dd..351703c5cb3 100644
--- a/homeassistant/components/aladdin_connect/cover.py
+++ b/homeassistant/components/aladdin_connect/cover.py
@@ -53,9 +53,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
except (TypeError, KeyError, NameError, ValueError) as ex:
_LOGGER.error("%s", ex)
hass.components.persistent_notification.create(
- "Error: {}
"
- "You will need to restart hass after fixing."
- "".format(ex),
+ "Error: {ex}
You will need to restart hass after fixing.",
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py
index e217bcb6cf9..06783df674d 100644
--- a/homeassistant/components/alarmdecoder/alarm_control_panel.py
+++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py
@@ -177,7 +177,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanel):
def alarm_arm_night(self, code=None):
"""Send arm night command."""
if code:
- self.hass.data[DATA_AD].send(f"{code!s}33")
+ self.hass.data[DATA_AD].send(f"{code!s}7")
def alarm_toggle_chime(self, code=None):
"""Send toggle chime command."""
diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py
index 3a473b17f17..9aa3c62e76c 100644
--- a/homeassistant/components/alert/__init__.py
+++ b/homeassistant/components/alert/__init__.py
@@ -31,7 +31,6 @@ from homeassistant.util.dt import now
_LOGGER = logging.getLogger(__name__)
DOMAIN = "alert"
-ENTITY_ID_FORMAT = DOMAIN + ".{}"
CONF_CAN_ACK = "can_acknowledge"
CONF_NOTIFIERS = "notifiers"
@@ -200,7 +199,7 @@ class Alert(ToggleEntity):
self._ack = False
self._cancel = None
self._send_done_message = False
- self.entity_id = ENTITY_ID_FORMAT.format(entity_id)
+ self.entity_id = f"{DOMAIN}.{entity_id}"
event.async_track_state_change(
hass, watched_entity_id, self.watched_entity_change
diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py
index 5861c4cc985..1355b0123b8 100644
--- a/homeassistant/components/alexa/__init__.py
+++ b/homeassistant/components/alexa/__init__.py
@@ -4,6 +4,7 @@ import logging
import voluptuous as vol
from homeassistant.const import CONF_NAME
+from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv, entityfilter
from . import flash_briefings, intent, smart_home_http
@@ -23,6 +24,7 @@ from .const import (
CONF_TITLE,
CONF_UID,
DOMAIN,
+ EVENT_ALEXA_SMART_HOME,
)
_LOGGER = logging.getLogger(__name__)
@@ -80,7 +82,37 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass, config):
"""Activate the Alexa component."""
- config = config.get(DOMAIN, {})
+
+ @callback
+ def async_describe_logbook_event(event):
+ """Describe a logbook event."""
+ data = event.data
+ entity_id = data["request"].get("entity_id")
+
+ if entity_id:
+ state = hass.states.get(entity_id)
+ name = state.name if state else entity_id
+ message = f"send command {data['request']['namespace']}/{data['request']['name']} for {name}"
+ else:
+ message = (
+ f"send command {data['request']['namespace']}/{data['request']['name']}"
+ )
+
+ 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
+ )
+
+ if DOMAIN not in config:
+ return True
+
+ config = config[DOMAIN]
+
flash_briefings_config = config.get(CONF_FLASH_BRIEFINGS)
intent.async_setup(hass)
diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py
index 94cf41d530b..6ab086ddda3 100644
--- a/homeassistant/components/alexa/capabilities.py
+++ b/homeassistant/components/alexa/capabilities.py
@@ -1,6 +1,5 @@
"""Alexa capabilities."""
import logging
-import math
from homeassistant.components import (
cover,
@@ -8,6 +7,7 @@ from homeassistant.components import (
image_processing,
input_number,
light,
+ timer,
vacuum,
)
from homeassistant.components.alarm_control_panel import ATTR_CODE_FORMAT, FORMAT_NUMBER
@@ -26,6 +26,7 @@ from homeassistant.const import (
STATE_ALARM_ARMED_CUSTOM_BYPASS,
STATE_ALARM_ARMED_HOME,
STATE_ALARM_ARMED_NIGHT,
+ STATE_IDLE,
STATE_LOCKED,
STATE_OFF,
STATE_ON,
@@ -227,7 +228,6 @@ class AlexaCapability:
"""Return properties serialized for an API response."""
for prop in self.properties_supported():
prop_name = prop["name"]
- # pylint: disable=assignment-from-no-return
prop_value = self.get_property(prop_name)
if prop_value is not None:
result = {
@@ -365,6 +365,10 @@ class AlexaPowerController(AlexaCapability):
if self.entity.domain == climate.DOMAIN:
is_on = self.entity.state != climate.HVAC_MODE_OFF
+ elif self.entity.domain == vacuum.DOMAIN:
+ is_on = self.entity.state == vacuum.STATE_CLEANING
+ elif self.entity.domain == timer.DOMAIN:
+ is_on = self.entity.state != STATE_IDLE
else:
is_on = self.entity.state != STATE_OFF
@@ -670,11 +674,8 @@ class AlexaSpeaker(AlexaCapability):
current_level = self.entity.attributes.get(
media_player.ATTR_MEDIA_VOLUME_LEVEL
)
- try:
- current = math.floor(int(current_level * 100))
- except ZeroDivisionError:
- current = 0
- return current
+ if current_level is not None:
+ return round(float(current_level) * 100)
if name == "muted":
return bool(
diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py
index bd579dc4dad..7d3a3994ace 100644
--- a/homeassistant/components/alexa/config.py
+++ b/homeassistant/components/alexa/config.py
@@ -53,7 +53,7 @@ class AbstractConfig(ABC):
)
try:
await self._unsub_proactive_report
- except Exception: # pylint: disable=broad-except
+ except Exception:
self._unsub_proactive_report = None
raise
diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py
index e45bcf824bc..ca1c6236fe6 100644
--- a/homeassistant/components/alexa/const.py
+++ b/homeassistant/components/alexa/const.py
@@ -6,6 +6,7 @@ from homeassistant.components.climate import const as climate
from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
DOMAIN = "alexa"
+EVENT_ALEXA_SMART_HOME = "alexa_smart_home"
# Flash briefing constants
CONF_UID = "uid"
diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py
index b10f11e2bbc..aa9fe40164c 100644
--- a/homeassistant/components/alexa/entities.py
+++ b/homeassistant/components/alexa/entities.py
@@ -400,7 +400,10 @@ class CoverCapabilities(AlexaEntity):
def interfaces(self):
"""Yield the supported interfaces."""
- yield AlexaPowerController(self.entity)
+ device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS)
+ if device_class != cover.DEVICE_CLASS_GARAGE:
+ yield AlexaPowerController(self.entity)
+
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & cover.SUPPORT_SET_POSITION:
yield AlexaRangeController(
@@ -724,6 +727,7 @@ class TimerCapabilities(AlexaEntity):
def interfaces(self):
"""Yield the supported interfaces."""
yield AlexaTimeHoldController(self.entity, allow_remote_resume=True)
+ yield AlexaPowerController(self.entity)
yield Alexa(self.entity)
@@ -738,8 +742,11 @@ class VacuumCapabilities(AlexaEntity):
def interfaces(self):
"""Yield the supported interfaces."""
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
- if (supported & vacuum.SUPPORT_TURN_ON) and (
- supported & vacuum.SUPPORT_TURN_OFF
+ if (
+ (supported & vacuum.SUPPORT_TURN_ON) or (supported & vacuum.SUPPORT_START)
+ ) and (
+ (supported & vacuum.SUPPORT_TURN_OFF)
+ or (supported & vacuum.SUPPORT_RETURN_HOME)
):
yield AlexaPowerController(self.entity)
diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py
index 03c5acd42fa..67083607769 100644
--- a/homeassistant/components/alexa/handlers.py
+++ b/homeassistant/components/alexa/handlers.py
@@ -121,6 +121,12 @@ async def async_api_turn_on(hass, config, directive, context):
service = SERVICE_TURN_ON
if domain == cover.DOMAIN:
service = cover.SERVICE_OPEN_COVER
+ elif domain == vacuum.DOMAIN:
+ supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ if not supported & vacuum.SUPPORT_TURN_ON and supported & vacuum.SUPPORT_START:
+ service = vacuum.SERVICE_START
+ elif domain == timer.DOMAIN:
+ service = timer.SERVICE_START
elif domain == media_player.DOMAIN:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
power_features = media_player.SUPPORT_TURN_ON | media_player.SUPPORT_TURN_OFF
@@ -149,6 +155,15 @@ async def async_api_turn_off(hass, config, directive, context):
service = SERVICE_TURN_OFF
if entity.domain == cover.DOMAIN:
service = cover.SERVICE_CLOSE_COVER
+ elif domain == vacuum.DOMAIN:
+ supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ if (
+ not supported & vacuum.SUPPORT_TURN_OFF
+ and supported & vacuum.SUPPORT_RETURN_HOME
+ ):
+ service = vacuum.SERVICE_RETURN_TO_BASE
+ elif domain == timer.DOMAIN:
+ service = timer.SERVICE_CANCEL
elif domain == media_player.DOMAIN:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
power_features = media_player.SUPPORT_TURN_ON | media_player.SUPPORT_TURN_OFF
@@ -478,8 +493,8 @@ async def async_api_select_input(hass, config, directive, context):
media_input = source
break
else:
- msg = "failed to map input {} to a media source on {}".format(
- media_input, entity.entity_id
+ msg = (
+ f"failed to map input {media_input} to a media source on {entity.entity_id}"
)
raise AlexaInvalidValueError(msg)
@@ -1225,7 +1240,7 @@ async def async_api_adjust_range(hass, config, directive, context):
service = SERVICE_SET_COVER_POSITION
current = entity.attributes.get(cover.ATTR_POSITION)
if not current:
- msg = "Unable to determine {} current position".format(entity.entity_id)
+ msg = f"Unable to determine {entity.entity_id} current position"
raise AlexaInvalidValueError(msg)
position = response_value = min(100, max(0, range_delta + current))
if position == 100:
@@ -1241,9 +1256,7 @@ async def async_api_adjust_range(hass, config, directive, context):
service = SERVICE_SET_COVER_TILT_POSITION
current = entity.attributes.get(cover.ATTR_TILT_POSITION)
if not current:
- msg = "Unable to determine {} current tilt position".format(
- entity.entity_id
- )
+ msg = f"Unable to determine {entity.entity_id} current tilt position"
raise AlexaInvalidValueError(msg)
tilt_position = response_value = min(100, max(0, range_delta + current))
if tilt_position == 100:
@@ -1439,9 +1452,7 @@ async def async_api_set_eq_mode(hass, config, directive, context):
if sound_mode_list and mode.lower() in sound_mode_list:
data[media_player.const.ATTR_SOUND_MODE] = mode.lower()
else:
- msg = "failed to map sound mode {} to a mode on {}".format(
- mode, entity.entity_id
- )
+ msg = f"failed to map sound mode {mode} to a mode on {entity.entity_id}"
raise AlexaInvalidValueError(msg)
await hass.services.async_call(
diff --git a/homeassistant/components/alexa/manifest.json b/homeassistant/components/alexa/manifest.json
index 5334cf765b8..bf8d4b08ba4 100644
--- a/homeassistant/components/alexa/manifest.json
+++ b/homeassistant/components/alexa/manifest.json
@@ -4,5 +4,6 @@
"documentation": "https://www.home-assistant.io/integrations/alexa",
"requirements": [],
"dependencies": ["http"],
+ "after_dependencies": ["logbook"],
"codeowners": ["@home-assistant/cloud", "@ochlocracy"]
}
diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py
index 9b0955f8fca..0f166ab3a27 100644
--- a/homeassistant/components/alexa/smart_home.py
+++ b/homeassistant/components/alexa/smart_home.py
@@ -3,15 +3,13 @@ import logging
import homeassistant.core as ha
-from .const import API_DIRECTIVE, API_HEADER
+from .const import API_DIRECTIVE, API_HEADER, EVENT_ALEXA_SMART_HOME
from .errors import AlexaBridgeUnreachableError, AlexaError
from .handlers import HANDLERS
from .messages import AlexaDirective
_LOGGER = logging.getLogger(__name__)
-EVENT_ALEXA_SMART_HOME = "alexa_smart_home"
-
async def async_handle_message(hass, config, request, context=None, enabled=True):
"""Handle incoming API messages.
diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py
index 44e1b7f4f55..b595bc98589 100644
--- a/homeassistant/components/alexa/state_report.py
+++ b/homeassistant/components/alexa/state_report.py
@@ -26,6 +26,9 @@ async def async_enable_proactive_mode(hass, smart_home_config):
await smart_home_config.async_get_access_token()
async def async_entity_state_listener(changed_entity, old_state, new_state):
+ if not hass.is_running:
+ return
+
if not new_state:
return
diff --git a/homeassistant/components/almond/config_flow.py b/homeassistant/components/almond/config_flow.py
index 42f9318a06f..b1eb506270b 100644
--- a/homeassistant/components/almond/config_flow.py
+++ b/homeassistant/components/almond/config_flow.py
@@ -87,7 +87,6 @@ class AlmondFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler):
)
return self.async_abort(reason="cannot_connect")
- # pylint: disable=invalid-name
self.CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
return self.async_create_entry(
diff --git a/homeassistant/components/ambient_station/.translations/da.json b/homeassistant/components/ambient_station/.translations/da.json
index 6cec31eca29..6428508687d 100644
--- a/homeassistant/components/ambient_station/.translations/da.json
+++ b/homeassistant/components/ambient_station/.translations/da.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "already_configured": "Denne appn\u00f8gle er allerede i brug."
+ },
"error": {
"identifier_exists": "Applikationsn\u00f8gle og/eller API n\u00f8gle er allerede registreret",
"invalid_key": "Ugyldig API n\u00f8gle og/eller applikationsn\u00f8gle",
diff --git a/homeassistant/components/ambient_station/.translations/es.json b/homeassistant/components/ambient_station/.translations/es.json
index d4222f1d2eb..d575db2ba71 100644
--- a/homeassistant/components/ambient_station/.translations/es.json
+++ b/homeassistant/components/ambient_station/.translations/es.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "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",
diff --git a/homeassistant/components/ambient_station/.translations/it.json b/homeassistant/components/ambient_station/.translations/it.json
index b468ba3673c..6bfaaac8f01 100644
--- a/homeassistant/components/ambient_station/.translations/it.json
+++ b/homeassistant/components/ambient_station/.translations/it.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "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",
diff --git a/homeassistant/components/ambient_station/.translations/lb.json b/homeassistant/components/ambient_station/.translations/lb.json
index 0f0d60d4458..891051bae00 100644
--- a/homeassistant/components/ambient_station/.translations/lb.json
+++ b/homeassistant/components/ambient_station/.translations/lb.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "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",
diff --git a/homeassistant/components/ambient_station/.translations/ru.json b/homeassistant/components/ambient_station/.translations/ru.json
index 438b1cf87a7..07f3907eea1 100644
--- a/homeassistant/components/ambient_station/.translations/ru.json
+++ b/homeassistant/components/ambient_station/.translations/ru.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "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.",
diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py
index 0bbb7a760fe..4fd6590b286 100644
--- a/homeassistant/components/ambient_station/__init__.py
+++ b/homeassistant/components/ambient_station/__init__.py
@@ -10,8 +10,11 @@ from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
ATTR_LOCATION,
ATTR_NAME,
+ CONCENTRATION_PARTS_PER_MILLION,
CONF_API_KEY,
EVENT_HOMEASSISTANT_STOP,
+ SPEED_MILES_PER_HOUR,
+ UNIT_PERCENTAGE,
)
from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady
@@ -23,14 +26,12 @@ from homeassistant.helpers.dispatcher import (
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_call_later
-from .config_flow import configured_instances
from .const import (
ATTR_LAST_DATA,
ATTR_MONITORED_CONDITIONS,
CONF_APP_KEY,
DATA_CLIENT,
DOMAIN,
- TOPIC_UPDATE,
TYPE_BINARY_SENSOR,
TYPE_SENSOR,
)
@@ -148,26 +149,26 @@ SENSOR_TYPES = {
TYPE_BATT8: ("Battery 8", None, TYPE_BINARY_SENSOR, "battery"),
TYPE_BATT9: ("Battery 9", None, TYPE_BINARY_SENSOR, "battery"),
TYPE_BATTOUT: ("Battery", None, TYPE_BINARY_SENSOR, "battery"),
- TYPE_CO2: ("co2", "ppm", TYPE_SENSOR, None),
+ TYPE_CO2: ("co2", CONCENTRATION_PARTS_PER_MILLION, TYPE_SENSOR, None),
TYPE_DAILYRAININ: ("Daily Rain", "in", TYPE_SENSOR, None),
TYPE_DEWPOINT: ("Dew Point", "°F", TYPE_SENSOR, "temperature"),
TYPE_EVENTRAININ: ("Event Rain", "in", TYPE_SENSOR, None),
TYPE_FEELSLIKE: ("Feels Like", "°F", TYPE_SENSOR, "temperature"),
TYPE_HOURLYRAININ: ("Hourly Rain Rate", "in/hr", TYPE_SENSOR, None),
- TYPE_HUMIDITY10: ("Humidity 10", "%", TYPE_SENSOR, "humidity"),
- TYPE_HUMIDITY1: ("Humidity 1", "%", TYPE_SENSOR, "humidity"),
- TYPE_HUMIDITY2: ("Humidity 2", "%", TYPE_SENSOR, "humidity"),
- TYPE_HUMIDITY3: ("Humidity 3", "%", TYPE_SENSOR, "humidity"),
- TYPE_HUMIDITY4: ("Humidity 4", "%", TYPE_SENSOR, "humidity"),
- TYPE_HUMIDITY5: ("Humidity 5", "%", TYPE_SENSOR, "humidity"),
- TYPE_HUMIDITY6: ("Humidity 6", "%", TYPE_SENSOR, "humidity"),
- TYPE_HUMIDITY7: ("Humidity 7", "%", TYPE_SENSOR, "humidity"),
- TYPE_HUMIDITY8: ("Humidity 8", "%", TYPE_SENSOR, "humidity"),
- TYPE_HUMIDITY9: ("Humidity 9", "%", TYPE_SENSOR, "humidity"),
- TYPE_HUMIDITY: ("Humidity", "%", TYPE_SENSOR, "humidity"),
- TYPE_HUMIDITYIN: ("Humidity In", "%", TYPE_SENSOR, "humidity"),
+ TYPE_HUMIDITY10: ("Humidity 10", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_HUMIDITY1: ("Humidity 1", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_HUMIDITY2: ("Humidity 2", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_HUMIDITY3: ("Humidity 3", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_HUMIDITY4: ("Humidity 4", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_HUMIDITY5: ("Humidity 5", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_HUMIDITY6: ("Humidity 6", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_HUMIDITY7: ("Humidity 7", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_HUMIDITY8: ("Humidity 8", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_HUMIDITY9: ("Humidity 9", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_HUMIDITY: ("Humidity", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_HUMIDITYIN: ("Humidity In", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
TYPE_LASTRAIN: ("Last Rain", None, TYPE_SENSOR, "timestamp"),
- TYPE_MAXDAILYGUST: ("Max Gust", "mph", TYPE_SENSOR, None),
+ TYPE_MAXDAILYGUST: ("Max Gust", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None),
TYPE_MONTHLYRAININ: ("Monthly Rain", "in", TYPE_SENSOR, None),
TYPE_RELAY10: ("Relay 10", None, TYPE_BINARY_SENSOR, "connectivity"),
TYPE_RELAY1: ("Relay 1", None, TYPE_BINARY_SENSOR, "connectivity"),
@@ -179,16 +180,16 @@ SENSOR_TYPES = {
TYPE_RELAY7: ("Relay 7", None, TYPE_BINARY_SENSOR, "connectivity"),
TYPE_RELAY8: ("Relay 8", None, TYPE_BINARY_SENSOR, "connectivity"),
TYPE_RELAY9: ("Relay 9", None, TYPE_BINARY_SENSOR, "connectivity"),
- TYPE_SOILHUM10: ("Soil Humidity 10", "%", TYPE_SENSOR, "humidity"),
- TYPE_SOILHUM1: ("Soil Humidity 1", "%", TYPE_SENSOR, "humidity"),
- TYPE_SOILHUM2: ("Soil Humidity 2", "%", TYPE_SENSOR, "humidity"),
- TYPE_SOILHUM3: ("Soil Humidity 3", "%", TYPE_SENSOR, "humidity"),
- TYPE_SOILHUM4: ("Soil Humidity 4", "%", TYPE_SENSOR, "humidity"),
- TYPE_SOILHUM5: ("Soil Humidity 5", "%", TYPE_SENSOR, "humidity"),
- TYPE_SOILHUM6: ("Soil Humidity 6", "%", TYPE_SENSOR, "humidity"),
- TYPE_SOILHUM7: ("Soil Humidity 7", "%", TYPE_SENSOR, "humidity"),
- TYPE_SOILHUM8: ("Soil Humidity 8", "%", TYPE_SENSOR, "humidity"),
- TYPE_SOILHUM9: ("Soil Humidity 9", "%", TYPE_SENSOR, "humidity"),
+ TYPE_SOILHUM10: ("Soil Humidity 10", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_SOILHUM1: ("Soil Humidity 1", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_SOILHUM2: ("Soil Humidity 2", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_SOILHUM3: ("Soil Humidity 3", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_SOILHUM4: ("Soil Humidity 4", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_SOILHUM5: ("Soil Humidity 5", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_SOILHUM6: ("Soil Humidity 6", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_SOILHUM7: ("Soil Humidity 7", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_SOILHUM8: ("Soil Humidity 8", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
+ TYPE_SOILHUM9: ("Soil Humidity 9", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"),
TYPE_SOILTEMP10F: ("Soil Temp 10", "°F", TYPE_SENSOR, "temperature"),
TYPE_SOILTEMP1F: ("Soil Temp 1", "°F", TYPE_SENSOR, "temperature"),
TYPE_SOILTEMP2F: ("Soil Temp 2", "°F", TYPE_SENSOR, "temperature"),
@@ -218,12 +219,12 @@ SENSOR_TYPES = {
TYPE_WEEKLYRAININ: ("Weekly Rain", "in", TYPE_SENSOR, None),
TYPE_WINDDIR: ("Wind Dir", "°", TYPE_SENSOR, None),
TYPE_WINDDIR_AVG10M: ("Wind Dir Avg 10m", "°", TYPE_SENSOR, None),
- TYPE_WINDDIR_AVG2M: ("Wind Dir Avg 2m", "mph", TYPE_SENSOR, None),
+ TYPE_WINDDIR_AVG2M: ("Wind Dir Avg 2m", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None),
TYPE_WINDGUSTDIR: ("Gust Dir", "°", TYPE_SENSOR, None),
- TYPE_WINDGUSTMPH: ("Wind Gust", "mph", TYPE_SENSOR, None),
- TYPE_WINDSPDMPH_AVG10M: ("Wind Avg 10m", "mph", TYPE_SENSOR, None),
- TYPE_WINDSPDMPH_AVG2M: ("Wind Avg 2m", "mph", TYPE_SENSOR, None),
- TYPE_WINDSPEEDMPH: ("Wind Speed", "mph", TYPE_SENSOR, None),
+ TYPE_WINDGUSTMPH: ("Wind Gust", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None),
+ TYPE_WINDSPDMPH_AVG10M: ("Wind Avg 10m", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None),
+ TYPE_WINDSPDMPH_AVG2M: ("Wind Avg 2m", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None),
+ TYPE_WINDSPEEDMPH: ("Wind Speed", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None),
TYPE_YEARLYRAININ: ("Yearly Rain", "in", TYPE_SENSOR, None),
}
@@ -253,9 +254,6 @@ async def async_setup(hass, config):
# Store config for use during entry setup:
hass.data[DOMAIN][DATA_CONFIG] = conf
- if conf[CONF_APP_KEY] in configured_instances(hass):
- return True
-
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
@@ -269,6 +267,11 @@ async def async_setup(hass, config):
async def async_setup_entry(hass, config_entry):
"""Set up the Ambient PWS as config entry."""
+ if not config_entry.unique_id:
+ hass.config_entries.async_update_entry(
+ config_entry, unique_id=config_entry.data[CONF_APP_KEY]
+ )
+
session = aiohttp_client.async_get_clientsession(hass)
try:
@@ -378,7 +381,9 @@ class AmbientStation:
if data != self.stations[mac_address][ATTR_LAST_DATA]:
_LOGGER.debug("New data received: %s", data)
self.stations[mac_address][ATTR_LAST_DATA] = data
- async_dispatcher_send(self._hass, TOPIC_UPDATE.format(mac_address))
+ async_dispatcher_send(
+ self._hass, f"ambient_station_data_update_{mac_address}"
+ )
_LOGGER.debug("Resetting watchdog")
self._watchdog_listener()
@@ -518,7 +523,7 @@ class AmbientWeatherEntity(Entity):
self.async_schedule_update_ha_state(True)
self._async_unsub_dispatcher_connect = async_dispatcher_connect(
- self.hass, TOPIC_UPDATE.format(self._mac_address), update
+ self.hass, f"ambient_station_data_update_{self._mac_address}", update
)
async def async_will_remove_from_hass(self):
diff --git a/homeassistant/components/ambient_station/config_flow.py b/homeassistant/components/ambient_station/config_flow.py
index c20b43598ca..c363a2839fb 100644
--- a/homeassistant/components/ambient_station/config_flow.py
+++ b/homeassistant/components/ambient_station/config_flow.py
@@ -5,35 +5,29 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_API_KEY
-from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client
-from .const import CONF_APP_KEY, DOMAIN
+from .const import CONF_APP_KEY, DOMAIN # pylint: disable=unused-import
-@callback
-def configured_instances(hass):
- """Return a set of configured Ambient PWS instances."""
- return set(
- entry.data[CONF_APP_KEY] for entry in hass.config_entries.async_entries(DOMAIN)
- )
-
-
-@config_entries.HANDLERS.register(DOMAIN)
-class AmbientStationFlowHandler(config_entries.ConfigFlow):
+class AmbientStationFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle an Ambient PWS config flow."""
VERSION = 2
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
- async def _show_form(self, errors=None):
- """Show the form to the user."""
- data_schema = vol.Schema(
+ def __init__(self):
+ """Initialize the config flow."""
+ self.data_schema = vol.Schema(
{vol.Required(CONF_API_KEY): str, vol.Required(CONF_APP_KEY): str}
)
+ async def _show_form(self, errors=None):
+ """Show the form to the user."""
return self.async_show_form(
- step_id="user", data_schema=data_schema, errors=errors if errors else {}
+ step_id="user",
+ data_schema=self.data_schema,
+ errors=errors if errors else {},
)
async def async_step_import(self, import_config):
@@ -42,12 +36,11 @@ class AmbientStationFlowHandler(config_entries.ConfigFlow):
async def async_step_user(self, user_input=None):
"""Handle the start of the config flow."""
-
if not user_input:
return await self._show_form()
- if user_input[CONF_APP_KEY] in configured_instances(self.hass):
- return await self._show_form({CONF_APP_KEY: "identifier_exists"})
+ await self.async_set_unique_id(user_input[CONF_APP_KEY])
+ self._abort_if_unique_id_configured()
session = aiohttp_client.async_get_clientsession(self.hass)
client = Client(user_input[CONF_API_KEY], user_input[CONF_APP_KEY], session)
diff --git a/homeassistant/components/ambient_station/const.py b/homeassistant/components/ambient_station/const.py
index 4f94e1cfe88..3b1990ae837 100644
--- a/homeassistant/components/ambient_station/const.py
+++ b/homeassistant/components/ambient_station/const.py
@@ -8,7 +8,5 @@ CONF_APP_KEY = "app_key"
DATA_CLIENT = "data_client"
-TOPIC_UPDATE = "ambient_station_data_update_{0}"
-
TYPE_BINARY_SENSOR = "binary_sensor"
TYPE_SENSOR = "sensor"
diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json
index 25f60f63abf..a6572070a5e 100644
--- a/homeassistant/components/ambient_station/manifest.json
+++ b/homeassistant/components/ambient_station/manifest.json
@@ -3,7 +3,7 @@
"name": "Ambient Weather Station",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ambient_station",
- "requirements": ["aioambient==1.0.2"],
+ "requirements": ["aioambient==1.0.4"],
"dependencies": [],
"codeowners": ["@bachya"]
}
diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py
index 6dc79cec326..c400d2ec97b 100644
--- a/homeassistant/components/ambient_station/sensor.py
+++ b/homeassistant/components/ambient_station/sensor.py
@@ -83,7 +83,11 @@ class AmbientWeatherSensor(AmbientWeatherEntity):
w_m2_brightness_val = self._ambient.stations[self._mac_address][
ATTR_LAST_DATA
].get(TYPE_SOLARRADIATION)
- self._state = round(float(w_m2_brightness_val) / 0.0079)
+
+ if w_m2_brightness_val is None:
+ self._state = None
+ else:
+ self._state = round(float(w_m2_brightness_val) / 0.0079)
else:
self._state = self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get(
self._sensor_type
diff --git a/homeassistant/components/ambient_station/strings.json b/homeassistant/components/ambient_station/strings.json
index 657b3477bb2..3cfe36b3220 100644
--- a/homeassistant/components/ambient_station/strings.json
+++ b/homeassistant/components/ambient_station/strings.json
@@ -11,9 +11,11 @@
}
},
"error": {
- "identifier_exists": "Application Key and/or API Key already registered",
"invalid_key": "Invalid API Key and/or Application Key",
"no_devices": "No devices found in account"
+ },
+ "abort": {
+ "already_configured": "This app key is already in use."
}
}
}
diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py
index 5578d350e22..b4b3e1866b4 100644
--- a/homeassistant/components/amcrest/__init__.py
+++ b/homeassistant/components/amcrest/__init__.py
@@ -109,7 +109,6 @@ CONFIG_SCHEMA = vol.Schema(
)
-# pylint: disable=too-many-ancestors
class AmcrestChecker(Http):
"""amcrest.Http wrapper for catching errors."""
diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py
index a99901f54a3..809b448876c 100644
--- a/homeassistant/components/amcrest/binary_sensor.py
+++ b/homeassistant/components/amcrest/binary_sensor.py
@@ -54,7 +54,7 @@ class AmcrestBinarySensor(BinarySensorDevice):
def __init__(self, name, device, sensor_type):
"""Initialize entity."""
- self._name = "{} {}".format(name, BINARY_SENSORS[sensor_type][0])
+ self._name = f"{name} {BINARY_SENSORS[sensor_type][0]}"
self._signal_name = name
self._api = device.api
self._sensor_type = sensor_type
diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py
index 0e64d4fefc9..f9515256403 100644
--- a/homeassistant/components/amcrest/camera.py
+++ b/homeassistant/components/amcrest/camera.py
@@ -491,9 +491,7 @@ class AmcrestCam(Camera):
"""Enable or disable indicator light."""
try:
self._api.command(
- "configManager.cgi?action=setConfig&LightGlobal[0].Enable={}".format(
- str(enable).lower()
- )
+ f"configManager.cgi?action=setConfig&LightGlobal[0].Enable={str(enable).lower()}"
)
except AmcrestError as error:
log_update_error(
diff --git a/homeassistant/components/amcrest/helpers.py b/homeassistant/components/amcrest/helpers.py
index a40d6ace50a..57d1a73c97e 100644
--- a/homeassistant/components/amcrest/helpers.py
+++ b/homeassistant/components/amcrest/helpers.py
@@ -6,7 +6,7 @@ def service_signal(service, ident=None):
"""Encode service and identifier into signal."""
signal = f"{DOMAIN}_{service}"
if ident:
- signal += "_{}".format(ident.replace(".", "_"))
+ signal += f"_{ident.replace('.', '_')}"
return signal
diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py
index 04436cd95ab..18abf28bdd5 100644
--- a/homeassistant/components/amcrest/sensor.py
+++ b/homeassistant/components/amcrest/sensor.py
@@ -4,7 +4,7 @@ import logging
from amcrest import AmcrestError
-from homeassistant.const import CONF_NAME, CONF_SENSORS
+from homeassistant.const import CONF_NAME, CONF_SENSORS, UNIT_PERCENTAGE
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
@@ -20,7 +20,7 @@ SENSOR_SDCARD = "sdcard"
# Sensor types are defined like: Name, units, icon
SENSORS = {
SENSOR_PTZ_PRESET: ["PTZ Preset", None, "mdi:camera-iris"],
- SENSOR_SDCARD: ["SD Used", "%", "mdi:sd"],
+ SENSOR_SDCARD: ["SD Used", UNIT_PERCENTAGE, "mdi:sd"],
}
@@ -45,7 +45,7 @@ class AmcrestSensor(Entity):
def __init__(self, name, device, sensor_type):
"""Initialize a sensor for Amcrest camera."""
- self._name = "{} {}".format(name, SENSORS[sensor_type][0])
+ self._name = f"{name} {SENSORS[sensor_type][0]}"
self._signal_name = name
self._api = device.api
self._sensor_type = sensor_type
@@ -98,15 +98,21 @@ class AmcrestSensor(Entity):
elif self._sensor_type == SENSOR_SDCARD:
storage = self._api.storage_all
try:
- self._attrs["Total"] = "{:.2f} {}".format(*storage["total"])
+ self._attrs[
+ "Total"
+ ] = f"{storage['total'][0]:.2f} {storage['total'][1]}"
except ValueError:
- self._attrs["Total"] = "{} {}".format(*storage["total"])
+ self._attrs[
+ "Total"
+ ] = f"{storage['total'][0]} {storage['total'][1]}"
try:
- self._attrs["Used"] = "{:.2f} {}".format(*storage["used"])
+ self._attrs[
+ "Used"
+ ] = f"{storage['used'][0]:.2f} {storage['used'][1]}"
except ValueError:
- self._attrs["Used"] = "{} {}".format(*storage["used"])
+ self._attrs["Used"] = f"{storage['used'][0]} {storage['used'][1]}"
try:
- self._state = "{:.2f}".format(storage["used_percent"])
+ self._state = f"{storage['used_percent']:.2f}"
except ValueError:
self._state = storage["used_percent"]
except AmcrestError as error:
diff --git a/homeassistant/components/anel_pwrctrl/switch.py b/homeassistant/components/anel_pwrctrl/switch.py
index 3c181d7d04b..19a0cc7c6ad 100644
--- a/homeassistant/components/anel_pwrctrl/switch.py
+++ b/homeassistant/components/anel_pwrctrl/switch.py
@@ -75,9 +75,7 @@ class PwrCtrlSwitch(SwitchDevice):
@property
def unique_id(self):
"""Return the unique ID of the device."""
- return "{device}-{switch_idx}".format(
- device=self._port.device.host, switch_idx=self._port.get_index()
- )
+ return f"{self._port.device.host}-{self._port.get_index()}"
@property
def name(self):
diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py
index 255eb1624ff..e39696cc37a 100644
--- a/homeassistant/components/apcupsd/sensor.py
+++ b/homeassistant/components/apcupsd/sensor.py
@@ -6,7 +6,14 @@ import voluptuous as vol
from homeassistant.components import apcupsd
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_RESOURCES, POWER_WATT, TEMP_CELSIUS
+from homeassistant.const import (
+ CONF_RESOURCES,
+ POWER_WATT,
+ TEMP_CELSIUS,
+ TIME_MINUTES,
+ TIME_SECONDS,
+ UNIT_PERCENTAGE,
+)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -22,7 +29,7 @@ SENSOR_TYPES = {
"battdate": ["Battery Replaced", "", "mdi:calendar-clock"],
"battstat": ["Battery Status", "", "mdi:information-outline"],
"battv": ["Battery Voltage", "V", "mdi:flash"],
- "bcharge": ["Battery", "%", "mdi:battery"],
+ "bcharge": ["Battery", UNIT_PERCENTAGE, "mdi:battery"],
"cable": ["Cable Type", "", "mdi:ethernet-cable"],
"cumonbatt": ["Total Time on Battery", "", "mdi:timer"],
"date": ["Status Date", "", "mdi:calendar-clock"],
@@ -36,20 +43,20 @@ SENSOR_TYPES = {
"firmware": ["Firmware Version", "", "mdi:information-outline"],
"hitrans": ["Transfer High", "V", "mdi:flash"],
"hostname": ["Hostname", "", "mdi:information-outline"],
- "humidity": ["Ambient Humidity", "%", "mdi:water-percent"],
+ "humidity": ["Ambient Humidity", UNIT_PERCENTAGE, "mdi:water-percent"],
"itemp": ["Internal Temperature", TEMP_CELSIUS, "mdi:thermometer"],
"lastxfer": ["Last Transfer", "", "mdi:transfer"],
"linefail": ["Input Voltage Status", "", "mdi:information-outline"],
"linefreq": ["Line Frequency", "Hz", "mdi:information-outline"],
"linev": ["Input Voltage", "V", "mdi:flash"],
- "loadpct": ["Load", "%", "mdi:gauge"],
- "loadapnt": ["Load Apparent Power", "%", "mdi:gauge"],
+ "loadpct": ["Load", UNIT_PERCENTAGE, "mdi:gauge"],
+ "loadapnt": ["Load Apparent Power", UNIT_PERCENTAGE, "mdi:gauge"],
"lotrans": ["Transfer Low", "V", "mdi:flash"],
"mandate": ["Manufacture Date", "", "mdi:calendar"],
"masterupd": ["Master Update", "", "mdi:information-outline"],
"maxlinev": ["Input Voltage High", "V", "mdi:flash"],
"maxtime": ["Battery Timeout", "", "mdi:timer-off"],
- "mbattchg": ["Battery Shutdown", "%", "mdi:battery-alert"],
+ "mbattchg": ["Battery Shutdown", UNIT_PERCENTAGE, "mdi:battery-alert"],
"minlinev": ["Input Voltage Low", "V", "mdi:flash"],
"mintimel": ["Shutdown Time", "", "mdi:timer"],
"model": ["Model", "", "mdi:information-outline"],
@@ -64,7 +71,7 @@ SENSOR_TYPES = {
"reg1": ["Register 1 Fault", "", "mdi:information-outline"],
"reg2": ["Register 2 Fault", "", "mdi:information-outline"],
"reg3": ["Register 3 Fault", "", "mdi:information-outline"],
- "retpct": ["Restore Requirement", "%", "mdi:battery-alert"],
+ "retpct": ["Restore Requirement", UNIT_PERCENTAGE, "mdi:battery-alert"],
"selftest": ["Last Self Test", "", "mdi:calendar-clock"],
"sense": ["Sensitivity", "", "mdi:information-outline"],
"serialno": ["Serial Number", "", "mdi:information-outline"],
@@ -84,16 +91,16 @@ SENSOR_TYPES = {
SPECIFIC_UNITS = {"ITEMP": TEMP_CELSIUS}
INFERRED_UNITS = {
- " Minutes": "min",
- " Seconds": "sec",
- " Percent": "%",
+ " Minutes": TIME_MINUTES,
+ " Seconds": TIME_SECONDS,
+ " Percent": UNIT_PERCENTAGE,
" Volts": "V",
" Ampere": "A",
" Volt-Ampere": "VA",
" Watts": POWER_WATT,
" Hz": "Hz",
" C": TEMP_CELSIUS,
- " Percent Load Capacity": "%",
+ " Percent Load Capacity": UNIT_PERCENTAGE,
}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py
index b9638d44d2b..e11bc5e61f9 100644
--- a/homeassistant/components/api/__init__.py
+++ b/homeassistant/components/api/__init__.py
@@ -26,7 +26,6 @@ from homeassistant.const import (
URL_API_EVENTS,
URL_API_SERVICES,
URL_API_STATES,
- URL_API_STATES_ENTITY,
URL_API_STREAM,
URL_API_TEMPLATE,
__version__,
@@ -254,7 +253,7 @@ class APIEntityStateView(HomeAssistantView):
status_code = HTTP_CREATED if is_new_state else 200
resp = self.json(hass.states.get(entity_id), status_code)
- resp.headers.add("Location", URL_API_STATES_ENTITY.format(entity_id))
+ resp.headers.add("Location", f"/api/states/{entity_id}")
return resp
diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py
index e11b246fd5e..52e02cfaf72 100644
--- a/homeassistant/components/apple_tv/__init__.py
+++ b/homeassistant/components/apple_tv/__init__.py
@@ -88,16 +88,15 @@ def request_configuration(hass, config, atv, credentials):
try:
await atv.airplay.finish_authentication(pin)
hass.components.persistent_notification.async_create(
- "Authentication succeeded!
Add the following "
- "to credentials: in your apple_tv configuration:
"
- "{0}".format(credentials),
+ f"Authentication succeeded!
"
+ f"Add the following to credentials: "
+ f"in your apple_tv configuration:
{credentials}",
title=NOTIFICATION_AUTH_TITLE,
notification_id=NOTIFICATION_AUTH_ID,
)
except DeviceAuthenticationError as ex:
hass.components.persistent_notification.async_create(
- "Authentication failed! Did you enter correct PIN?
"
- "Details: {0}".format(ex),
+ f"Authentication failed! Did you enter correct PIN?
Details: {ex}",
title=NOTIFICATION_AUTH_TITLE,
notification_id=NOTIFICATION_AUTH_ID,
)
@@ -124,9 +123,7 @@ async def scan_apple_tvs(hass):
if login_id is None:
login_id = "Home Sharing disabled"
devices.append(
- "Name: {0}
Host: {1}
Login ID: {2}".format(
- atv.name, atv.address, login_id
- )
+ f"Name: {atv.name}
Host: {atv.address}
Login ID: {login_id}"
)
if not devices:
diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py
index 6258b470ebb..fb29a0ac8c7 100644
--- a/homeassistant/components/aprs/device_tracker.py
+++ b/homeassistant/components/aprs/device_tracker.py
@@ -57,7 +57,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
def make_filter(callsigns: list) -> str:
"""Make a server-side filter from a list of callsigns."""
- return " ".join("b/{0}".format(cs.upper()) for cs in callsigns)
+ return " ".join(f"b/{sign.upper()}" for sign in callsigns)
def gps_accuracy(gps, posambiguity: int) -> int:
diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py
index 1cc06fc446f..dde092dd1fa 100644
--- a/homeassistant/components/aqualogic/sensor.py
+++ b/homeassistant/components/aqualogic/sensor.py
@@ -4,7 +4,12 @@ import logging
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, TEMP_FAHRENHEIT
+from homeassistant.const import (
+ CONF_MONITORED_CONDITIONS,
+ TEMP_CELSIUS,
+ TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
+)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -14,7 +19,7 @@ from . import DOMAIN, UPDATE_TOPIC
_LOGGER = logging.getLogger(__name__)
TEMP_UNITS = [TEMP_CELSIUS, TEMP_FAHRENHEIT]
-PERCENT_UNITS = ["%", "%"]
+PERCENT_UNITS = [UNIT_PERCENTAGE, UNIT_PERCENTAGE]
SALT_UNITS = ["g/L", "PPM"]
WATT_UNITS = ["W", "W"]
NO_UNITS = [None, None]
@@ -70,7 +75,7 @@ class AquaLogicSensor(Entity):
@property
def name(self):
"""Return the name of the sensor."""
- return "AquaLogic {}".format(SENSOR_TYPES[self._type][0])
+ return f"AquaLogic {SENSOR_TYPES[self._type][0]}"
@property
def unit_of_measurement(self):
diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py
index 74f1a9d9f9a..6950929ee80 100644
--- a/homeassistant/components/aqualogic/switch.py
+++ b/homeassistant/components/aqualogic/switch.py
@@ -70,7 +70,7 @@ class AquaLogicSwitch(SwitchDevice):
@property
def name(self):
"""Return the name of the switch."""
- return "AquaLogic {}".format(SWITCH_TYPES[self._type])
+ return f"AquaLogic {SWITCH_TYPES[self._type]}"
@property
def should_poll(self):
diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py
index d818414753f..59bcd08a641 100644
--- a/homeassistant/components/arcam_fmj/__init__.py
+++ b/homeassistant/components/arcam_fmj/__init__.py
@@ -44,9 +44,9 @@ def _optional_zone(value):
def _zone_name_validator(config):
for zone, zone_config in config[CONF_ZONE].items():
if CONF_NAME not in zone_config:
- zone_config[CONF_NAME] = "{} ({}:{}) - {}".format(
- DEFAULT_NAME, config[CONF_HOST], config[CONF_PORT], zone
- )
+ zone_config[
+ CONF_NAME
+ ] = f"{DEFAULT_NAME} ({config[CONF_HOST]}:{config[CONF_PORT]}) - {zone}"
return config
diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py
index 2533ce3619e..1bb34a11693 100644
--- a/homeassistant/components/arest/sensor.py
+++ b/homeassistant/components/arest/sensor.py
@@ -140,7 +140,7 @@ class ArestSensor(Entity):
"""Initialize the sensor."""
self.arest = arest
self._resource = resource
- self._name = "{} {}".format(location.title(), name.title())
+ self._name = f"{location.title()} {name.title()}"
self._variable = variable
self._pin = pin
self._state = None
@@ -204,8 +204,7 @@ class ArestData:
try:
if str(self._pin[0]) == "A":
response = requests.get(
- "{}/analog/{}".format(self._resource, self._pin[1:]),
- timeout=10,
+ f"{self._resource,}/analog/{self._pin[1:]}", timeout=10
)
self.data = {"value": response.json()["return_value"]}
except TypeError:
diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py
index ccc2c5d8bf5..d3a51391627 100644
--- a/homeassistant/components/arest/switch.py
+++ b/homeassistant/components/arest/switch.py
@@ -86,7 +86,7 @@ class ArestSwitchBase(SwitchDevice):
def __init__(self, resource, location, name):
"""Initialize the switch."""
self._resource = resource
- self._name = "{} {}".format(location.title(), name.title())
+ self._name = f"{location.title()} {name.title()}"
self._state = None
self._available = True
diff --git a/homeassistant/components/arlo/__init__.py b/homeassistant/components/arlo/__init__.py
index df24bdd1a92..40d75d557bb 100644
--- a/homeassistant/components/arlo/__init__.py
+++ b/homeassistant/components/arlo/__init__.py
@@ -67,9 +67,7 @@ def setup(hass, config):
except (ConnectTimeout, HTTPError) as ex:
_LOGGER.error("Unable to connect to Netgear Arlo: %s", str(ex))
hass.components.persistent_notification.create(
- "Error: {}
"
- "You will need to restart hass after fixing."
- "".format(ex),
+ f"Error: {ex}
You will need to restart hass after fixing.",
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
diff --git a/homeassistant/components/arlo/camera.py b/homeassistant/components/arlo/camera.py
index 22b1bfbe810..8152a76feec 100644
--- a/homeassistant/components/arlo/camera.py
+++ b/homeassistant/components/arlo/camera.py
@@ -83,8 +83,9 @@ class ArloCam(Camera):
)
if not video:
- error_msg = "Video not found for {0}. Is it older than {1} days?".format(
- self.name, self._camera.min_days_vdo_cache
+ error_msg = (
+ f"Video not found for {self.name}. "
+ f"Is it older than {self._camera.min_days_vdo_cache} days?"
)
_LOGGER.error(error_msg)
return
diff --git a/homeassistant/components/arlo/sensor.py b/homeassistant/components/arlo/sensor.py
index aadd5a48d37..03e4437b257 100644
--- a/homeassistant/components/arlo/sensor.py
+++ b/homeassistant/components/arlo/sensor.py
@@ -6,10 +6,12 @@ import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import (
ATTR_ATTRIBUTION,
+ CONCENTRATION_PARTS_PER_MILLION,
CONF_MONITORED_CONDITIONS,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
@@ -26,11 +28,11 @@ SENSOR_TYPES = {
"last_capture": ["Last", None, "run-fast"],
"total_cameras": ["Arlo Cameras", None, "video"],
"captured_today": ["Captured Today", None, "file-video"],
- "battery_level": ["Battery Level", "%", "battery-50"],
+ "battery_level": ["Battery Level", UNIT_PERCENTAGE, "battery-50"],
"signal_strength": ["Signal Strength", None, "signal"],
"temperature": ["Temperature", TEMP_CELSIUS, "thermometer"],
- "humidity": ["Humidity", "%", "water-percent"],
- "air_quality": ["Air Quality", "ppm", "biohazard"],
+ "humidity": ["Humidity", UNIT_PERCENTAGE, "water-percent"],
+ "air_quality": ["Air Quality", CONCENTRATION_PARTS_PER_MILLION, "biohazard"],
}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@@ -57,7 +59,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
if sensor_type in ("temperature", "humidity", "air_quality"):
continue
- name = "{0} {1}".format(SENSOR_TYPES[sensor_type][0], camera.name)
+ name = f"{SENSOR_TYPES[sensor_type][0]} {camera.name}"
sensors.append(ArloSensor(name, camera, sensor_type))
for base_station in arlo.base_stations:
@@ -65,9 +67,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
sensor_type in ("temperature", "humidity", "air_quality")
and base_station.model_id == "ABC1000"
):
- name = "{0} {1}".format(
- SENSOR_TYPES[sensor_type][0], base_station.name
- )
+ name = f"{SENSOR_TYPES[sensor_type][0]} {base_station.name}"
sensors.append(ArloSensor(name, base_station, sensor_type))
add_entities(sensors, True)
@@ -83,7 +83,7 @@ class ArloSensor(Entity):
self._data = device
self._sensor_type = sensor_type
self._state = None
- self._icon = "mdi:{}".format(SENSOR_TYPES.get(self._sensor_type)[2])
+ self._icon = f"mdi:{SENSOR_TYPES.get(self._sensor_type)[2]}"
@property
def name(self):
@@ -141,8 +141,9 @@ class ArloSensor(Entity):
video = self._data.last_video
self._state = video.created_at_pretty("%m-%d-%Y %H:%M:%S")
except (AttributeError, IndexError):
- error_msg = "Video not found for {0}. Older than {1} days?".format(
- self.name, self._data.min_days_vdo_cache
+ error_msg = (
+ f"Video not found for {self.name}. "
+ f"Older than {self._data.min_days_vdo_cache} days?"
)
_LOGGER.debug(error_msg)
self._state = None
diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py
index 485c731ff6a..355bcad3aaf 100644
--- a/homeassistant/components/aruba/device_tracker.py
+++ b/homeassistant/components/aruba/device_tracker.py
@@ -84,8 +84,8 @@ class ArubaDeviceScanner(DeviceScanner):
def get_aruba_data(self):
"""Retrieve data from Aruba Access Point and return parsed result."""
- connect = "ssh {}@{}"
- ssh = pexpect.spawn(connect.format(self.username, self.host))
+ connect = f"ssh {self.username}@{self.host}"
+ ssh = pexpect.spawn(connect)
query = ssh.expect(
[
"password:",
diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py
index 685e5d90f53..014c46fd73c 100644
--- a/homeassistant/components/arwn/sensor.py
+++ b/homeassistant/components/arwn/sensor.py
@@ -50,7 +50,7 @@ def discover_sensors(topic, payload):
def _slug(name):
- return "sensor.arwn_{}".format(slugify(name))
+ return f"sensor.arwn_{slugify(name)}"
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
diff --git a/homeassistant/components/asterisk_cdr/mailbox.py b/homeassistant/components/asterisk_cdr/mailbox.py
index 0bae6ebf3ad..12587e531d7 100644
--- a/homeassistant/components/asterisk_cdr/mailbox.py
+++ b/homeassistant/components/asterisk_cdr/mailbox.py
@@ -49,8 +49,10 @@ class AsteriskCDR(Mailbox):
"duration": entry["duration"],
}
sha = hashlib.sha256(str(entry).encode("utf-8")).hexdigest()
- msg = "Destination: {}\nApplication: {}\n Context: {}".format(
- entry["dest"], entry["application"], entry["context"]
+ msg = (
+ f"Destination: {entry['dest']}\n"
+ f"Application: {entry['application']}\n "
+ f"Context: {entry['context']}"
)
cdr.append({"info": info, "sha": sha, "text": msg})
self.cdr = cdr
diff --git a/homeassistant/components/asuswrt/__init__.py b/homeassistant/components/asuswrt/__init__.py
index 897258c6299..f2d7a72e54d 100644
--- a/homeassistant/components/asuswrt/__init__.py
+++ b/homeassistant/components/asuswrt/__init__.py
@@ -17,6 +17,8 @@ from homeassistant.helpers.discovery import async_load_platform
_LOGGER = logging.getLogger(__name__)
+CONF_DNSMASQ = "dnsmasq"
+CONF_INTERFACE = "interface"
CONF_PUB_KEY = "pub_key"
CONF_REQUIRE_IP = "require_ip"
CONF_SENSORS = "sensors"
@@ -24,7 +26,10 @@ CONF_SSH_KEY = "ssh_key"
DOMAIN = "asuswrt"
DATA_ASUSWRT = DOMAIN
+
DEFAULT_SSH_PORT = 22
+DEFAULT_INTERFACE = "eth0"
+DEFAULT_DNSMASQ = "/var/lib/misc"
SECRET_GROUP = "Password or SSH Key"
SENSOR_TYPES = ["upload_speed", "download_speed", "download", "upload"]
@@ -45,6 +50,8 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_SENSORS): vol.All(
cv.ensure_list, [vol.In(SENSOR_TYPES)]
),
+ vol.Optional(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string,
+ vol.Optional(CONF_DNSMASQ, default=DEFAULT_DNSMASQ): cv.isdir,
}
)
},
@@ -59,13 +66,15 @@ async def async_setup(hass, config):
api = AsusWrt(
conf[CONF_HOST],
- conf.get(CONF_PORT),
- conf.get(CONF_PROTOCOL) == "telnet",
+ conf[CONF_PORT],
+ conf[CONF_PROTOCOL] == "telnet",
conf[CONF_USERNAME],
conf.get(CONF_PASSWORD, ""),
conf.get("ssh_key", conf.get("pub_key", "")),
- conf.get(CONF_MODE),
- conf.get(CONF_REQUIRE_IP),
+ conf[CONF_MODE],
+ conf[CONF_REQUIRE_IP],
+ conf[CONF_INTERFACE],
+ conf[CONF_DNSMASQ],
)
await api.connection.async_connect()
diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json
index 416144b450c..c161dc4f536 100644
--- a/homeassistant/components/asuswrt/manifest.json
+++ b/homeassistant/components/asuswrt/manifest.json
@@ -2,7 +2,7 @@
"domain": "asuswrt",
"name": "ASUSWRT",
"documentation": "https://www.home-assistant.io/integrations/asuswrt",
- "requirements": ["aioasuswrt==1.1.22"],
+ "requirements": ["aioasuswrt==1.2.2"],
"dependencies": [],
"codeowners": ["@kennedyshead"]
}
diff --git a/homeassistant/components/august/.translations/ca.json b/homeassistant/components/august/.translations/ca.json
new file mode 100644
index 00000000000..561b91799be
--- /dev/null
+++ b/homeassistant/components/august/.translations/ca.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El compte ja ha estat 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": {
+ "login_method": "M\u00e8tode d'inici de sessi\u00f3",
+ "password": "Contrasenya",
+ "timeout": "Temps d'espera (segons)",
+ "username": "Nom d'usuari"
+ },
+ "description": "Si el m\u00e8tode d'inici de sessi\u00f3 \u00e9s 'email', el nom d'usuari \u00e9s l'adre\u00e7a de correu electr\u00f2nic. Si el m\u00e8tode d'inici de sessi\u00f3 \u00e9s 'phone', el nom d'usuari \u00e9s el n\u00famero de tel\u00e8fon en el format \"+NNNNNNNNN\".",
+ "title": "Configuraci\u00f3 de compte August"
+ },
+ "validation": {
+ "data": {
+ "code": "Codi de verificaci\u00f3"
+ },
+ "description": "Comprova el teu {login_method} ({username}) i introdueix el codi de verificaci\u00f3 a continuaci\u00f3",
+ "title": "Autenticaci\u00f3 de dos factors"
+ }
+ },
+ "title": "August"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/august/.translations/da.json b/homeassistant/components/august/.translations/da.json
new file mode 100644
index 00000000000..d63bcf9acca
--- /dev/null
+++ b/homeassistant/components/august/.translations/da.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Kontoen er allerede konfigureret"
+ },
+ "error": {
+ "cannot_connect": "Kunne ikke oprette forbindelse. Pr\u00f8v igen",
+ "invalid_auth": "Ugyldig godkendelse",
+ "unknown": "Uventet fejl"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "login_method": "Loginmetode",
+ "password": "Adgangskode",
+ "timeout": "Timeout (sekunder)",
+ "username": "Brugernavn"
+ },
+ "description": "Hvis loginmetoden er 'e-mail', er brugernavn e-mailadressen. Hvis loginmetoden er 'telefon', er brugernavn telefonnummeret i formatet '+NNNNNNNNNN'.",
+ "title": "Konfigurer en August-konto"
+ },
+ "validation": {
+ "data": {
+ "code": "Bekr\u00e6ftelseskode"
+ },
+ "description": "Kontroller dit {login_method} ({username}), og angiv bekr\u00e6ftelseskoden nedenfor",
+ "title": "Tofaktorgodkendelse"
+ }
+ },
+ "title": "August"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/august/.translations/es.json b/homeassistant/components/august/.translations/es.json
new file mode 100644
index 00000000000..58d94bb0cbf
--- /dev/null
+++ b/homeassistant/components/august/.translations/es.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "La cuenta ya est\u00e1 configurada"
+ },
+ "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": {
+ "login_method": "M\u00e9todo de inicio de sesi\u00f3n",
+ "password": "Contrase\u00f1a",
+ "timeout": "Tiempo de espera (segundos)",
+ "username": "Usuario"
+ },
+ "description": "Si el M\u00e9todo de Inicio de Sesi\u00f3n es 'correo electr\u00f3nico', Usuario es la direcci\u00f3n de correo electr\u00f3nico. Si el M\u00e9todo de Inicio de Sesi\u00f3n es 'tel\u00e9fono', Usuario es el n\u00famero de tel\u00e9fono en formato '+NNNNNNNNN'.",
+ "title": "Configurar una cuenta de August"
+ },
+ "validation": {
+ "data": {
+ "code": "C\u00f3digo de verificaci\u00f3n"
+ },
+ "description": "Por favor, compruebe tu {login_method} ({username}) e introduce el c\u00f3digo de verificaci\u00f3n a continuaci\u00f3n",
+ "title": "Autenticaci\u00f3n de dos factores"
+ }
+ },
+ "title": "August"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/august/.translations/it.json b/homeassistant/components/august/.translations/it.json
new file mode 100644
index 00000000000..98445345f96
--- /dev/null
+++ b/homeassistant/components/august/.translations/it.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'account \u00e8 gi\u00e0 configurato"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi, si prega di riprovare.",
+ "invalid_auth": "Autenticazione non valida",
+ "unknown": "Errore imprevisto"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "login_method": "Metodo di accesso",
+ "password": "Password",
+ "timeout": "Timeout (in secondi)",
+ "username": "Nome utente"
+ },
+ "description": "Se il metodo di accesso \u00e8 \"e-mail\", il nome utente \u00e8 l'indirizzo e-mail. Se il metodo di accesso \u00e8 \"telefono\", il nome utente \u00e8 il numero di telefono nel formato \"+NNNNNNNNN\".",
+ "title": "Configura un account di August"
+ },
+ "validation": {
+ "data": {
+ "code": "Codice di verifica"
+ },
+ "description": "Controlla il tuo {login_method} ({username}) e inserisci il codice di verifica seguente",
+ "title": "Autenticazione a due fattori"
+ }
+ },
+ "title": "August"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/august/.translations/lb.json b/homeassistant/components/august/.translations/lb.json
new file mode 100644
index 00000000000..514ad6786d4
--- /dev/null
+++ b/homeassistant/components/august/.translations/lb.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Kont ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "cannot_connect": "Feeler beim verbannen, prob\u00e9iert w.e.g. nach emol.",
+ "invalid_auth": "Ong\u00eblteg Authentifikatioun",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "login_method": "Login Method",
+ "password": "Passwuert",
+ "timeout": "Z\u00e4itiwwerscheidung (sekonnen)",
+ "username": "Benotzernumm"
+ },
+ "description": "Wann d'Login Method 'E-Mail' ass, dannn ass de Benotzernumm d'E-Mail Adress. Wann d'Login-Method 'Telefon' ass, ass den Benotzernumm d'Telefonsnummer am Format '+ NNNNNNNNN'.",
+ "title": "August Kont ariichten"
+ },
+ "validation": {
+ "data": {
+ "code": "Verifikatiouns Code"
+ },
+ "description": "Pr\u00e9ift w.e.g. \u00c4re {login_method} ({username}) a gitt de Verifikatiounscode hei dr\u00ebnner an",
+ "title": "2-Faktor-Authentifikatioun"
+ }
+ },
+ "title": "August"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/august/.translations/lv.json b/homeassistant/components/august/.translations/lv.json
new file mode 100644
index 00000000000..b2afeaf0874
--- /dev/null
+++ b/homeassistant/components/august/.translations/lv.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "login_method": "Pieteik\u0161an\u0101s metode",
+ "password": "Parole"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/august/.translations/no.json b/homeassistant/components/august/.translations/no.json
new file mode 100644
index 00000000000..61193656b51
--- /dev/null
+++ b/homeassistant/components/august/.translations/no.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Kontoen 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": {
+ "login_method": "P\u00e5loggingsmetode",
+ "password": "Passord",
+ "timeout": "Tidsavbrudd (sekunder)",
+ "username": "Brukernavn"
+ },
+ "description": "Hvis p\u00e5loggingsmetoden er 'e-post', er brukernavnet e-postadressen. Hvis p\u00e5loggingsmetoden er 'telefon', er brukernavn telefonnummeret i formatet '+ NNNNNNNNN'.",
+ "title": "Sett opp en August konto"
+ },
+ "validation": {
+ "data": {
+ "code": "Bekreftelseskode"
+ },
+ "description": "Kontroller {login_method} ( {username} ) og skriv inn bekreftelseskoden nedenfor",
+ "title": "To-faktor autentisering"
+ }
+ },
+ "title": "August"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/august/.translations/ru.json b/homeassistant/components/august/.translations/ru.json
new file mode 100644
index 00000000000..fc90b3e8bb5
--- /dev/null
+++ b/homeassistant/components/august/.translations/ru.json
@@ -0,0 +1,32 @@
+{
+ "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": {
+ "login_method": "\u0421\u043f\u043e\u0441\u043e\u0431 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438",
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c",
+ "timeout": "\u0422\u0430\u0439\u043c-\u0430\u0443\u0442 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)",
+ "username": "\u041b\u043e\u0433\u0438\u043d"
+ },
+ "description": "\u0415\u0441\u043b\u0438 \u0432 \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0430 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0432\u044b\u0431\u0440\u0430\u043d\u043e 'email', \u0442\u043e \u043b\u043e\u0433\u0438\u043d\u043e\u043c \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0430\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b. \u0415\u0441\u043b\u0438 \u0432 \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0430 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0432\u044b\u0431\u0440\u0430\u043d\u043e 'phone', \u0442\u043e \u043b\u043e\u0433\u0438\u043d\u043e\u043c \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430 \u0432 \u0444\u043e\u0440\u043c\u0430\u0442\u0435 '+NNNNNNNNN'.",
+ "title": "August"
+ },
+ "validation": {
+ "data": {
+ "code": "\u041a\u043e\u0434 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u044f"
+ },
+ "description": "\u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 {login_method} ({username}) \u0438 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0439 \u043f\u0440\u043e\u0432\u0435\u0440\u043e\u0447\u043d\u044b\u0439 \u043a\u043e\u0434.",
+ "title": "\u0414\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f"
+ }
+ },
+ "title": "August"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/august/.translations/zh-Hant.json b/homeassistant/components/august/.translations/zh-Hant.json
new file mode 100644
index 00000000000..193b9a46e3f
--- /dev/null
+++ b/homeassistant/components/august/.translations/zh-Hant.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u5e33\u865f\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": {
+ "login_method": "\u767b\u5165\u65b9\u5f0f",
+ "password": "\u5bc6\u78bc",
+ "timeout": "\u903e\u6642\uff08\u79d2\uff09",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31"
+ },
+ "description": "\u5047\u5982\u767b\u5165\u65b9\u5f0f\u70ba\u90f5\u4ef6\u300cemail\u300d\u3001\u4f7f\u7528\u8005\u540d\u7a31\u70ba\u96fb\u5b50\u90f5\u4ef6\u4f4d\u5740\u3002\u5047\u5982\u767b\u5165\u65b9\u5f0f\u70ba\u96fb\u8a71\u300cphone\u300d\u3001\u5247\u4f7f\u7528\u8005\u540d\u7a31\u70ba\u5305\u542b\u570b\u78bc\u4e4b\u96fb\u8a71\u865f\u78bc\uff0c\u5982\u300c+NNNNNNNNN\u300d\u3002",
+ "title": "\u8a2d\u5b9a August \u5e33\u865f"
+ },
+ "validation": {
+ "data": {
+ "code": "\u9a57\u8b49\u78bc"
+ },
+ "description": "\u8acb\u78ba\u8a8d {login_method} ({username}) \u4e26\u65bc\u4e0b\u65b9\u8f38\u5165\u9a57\u8b49\u78bc",
+ "title": "\u5169\u6b65\u9a5f\u9a57\u8b49"
+ }
+ },
+ "title": "August"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py
index 67e177d11d9..373fcae8d0c 100644
--- a/homeassistant/components/august/__init__.py
+++ b/homeassistant/components/august/__init__.py
@@ -1,67 +1,41 @@
"""Support for August devices."""
import asyncio
-from datetime import timedelta
-from functools import partial
+import itertools
import logging
-from august.api import Api, AugustApiHTTPError
-from august.authenticator import AuthenticationState, Authenticator, ValidationResult
-from requests import RequestException, Session
+from aiohttp import ClientError
+from august.authenticator import ValidationResult
+from august.exceptions import AugustApiAIOHTTPError
import voluptuous as vol
-from homeassistant.const import (
- CONF_PASSWORD,
- CONF_TIMEOUT,
- CONF_USERNAME,
- EVENT_HOMEASSISTANT_STOP,
-)
-from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import discovery
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
import homeassistant.helpers.config_validation as cv
-from homeassistant.util import Throttle, dt
+
+from .activity import ActivityStream
+from .const import (
+ AUGUST_COMPONENTS,
+ CONF_ACCESS_TOKEN_CACHE_FILE,
+ CONF_INSTALL_ID,
+ CONF_LOGIN_METHOD,
+ DATA_AUGUST,
+ DEFAULT_AUGUST_CONFIG_FILE,
+ DEFAULT_NAME,
+ DEFAULT_TIMEOUT,
+ DOMAIN,
+ LOGIN_METHODS,
+ MIN_TIME_BETWEEN_DETAIL_UPDATES,
+ VERIFICATION_CODE_KEY,
+)
+from .exceptions import InvalidAuth, RequireValidation
+from .gateway import AugustGateway
+from .subscriber import AugustSubscriberMixin
_LOGGER = logging.getLogger(__name__)
-_CONFIGURING = {}
-
-DEFAULT_TIMEOUT = 10
-ACTIVITY_FETCH_LIMIT = 10
-ACTIVITY_INITIAL_FETCH_LIMIT = 20
-
-CONF_LOGIN_METHOD = "login_method"
-CONF_INSTALL_ID = "install_id"
-
-NOTIFICATION_ID = "august_notification"
-NOTIFICATION_TITLE = "August Setup"
-
-AUGUST_CONFIG_FILE = ".august.conf"
-
-DATA_AUGUST = "august"
-DOMAIN = "august"
-DEFAULT_ENTITY_NAMESPACE = "august"
-
-# Limit battery and hardware updates to 1800 seconds
-# in order to reduce the number of api requests and
-# avoid hitting rate limits
-MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES = timedelta(seconds=1800)
-
-# Limit locks status check to 900 seconds now that
-# we get the state from the lock and unlock api calls
-# and the lock and unlock activities are now captured
-MIN_TIME_BETWEEN_LOCK_STATUS_UPDATES = timedelta(seconds=900)
-
-# Doorbells need to update more frequently than locks
-# since we get an image from the doorbell api
-MIN_TIME_BETWEEN_DOORBELL_STATUS_UPDATES = timedelta(seconds=20)
-
-# Activity needs to be checked more frequently as the
-# doorbell motion and rings are included here
-MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10)
-
-DEFAULT_SCAN_INTERVAL = timedelta(seconds=10)
-
-
-LOGIN_METHODS = ["phone", "email"]
+TWO_FA_REVALIDATE = "verify_configurator"
CONFIG_SCHEMA = vol.Schema(
{
@@ -78,447 +52,315 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
-AUGUST_COMPONENTS = ["camera", "binary_sensor", "lock"]
+async def async_request_validation(hass, config_entry, august_gateway):
+ """Request a new verification code from the user."""
-def request_configuration(hass, config, api, authenticator, token_refresh_lock):
- """Request configuration steps from the user."""
+ #
+ # In the future this should start a new config flow
+ # instead of using the legacy configurator
+ #
+ _LOGGER.error("Access token is no longer valid.")
configurator = hass.components.configurator
+ entry_id = config_entry.entry_id
- def august_configuration_callback(data):
- """Run when the configuration callback is called."""
-
- result = authenticator.validate_verification_code(data.get("verification_code"))
+ async def async_august_configuration_validation_callback(data):
+ code = data.get(VERIFICATION_CODE_KEY)
+ result = await august_gateway.authenticator.async_validate_verification_code(
+ code
+ )
if result == ValidationResult.INVALID_VERIFICATION_CODE:
- configurator.notify_errors(
- _CONFIGURING[DOMAIN], "Invalid verification code"
+ configurator.async_notify_errors(
+ hass.data[DOMAIN][entry_id][TWO_FA_REVALIDATE],
+ "Invalid verification code, please make sure you are using the latest code and try again.",
)
elif result == ValidationResult.VALIDATED:
- setup_august(hass, config, api, authenticator, token_refresh_lock)
+ return await async_setup_august(hass, config_entry, august_gateway)
- if DOMAIN not in _CONFIGURING:
- authenticator.send_verification_code()
+ return False
- conf = config[DOMAIN]
- username = conf.get(CONF_USERNAME)
- login_method = conf.get(CONF_LOGIN_METHOD)
+ if TWO_FA_REVALIDATE not in hass.data[DOMAIN][entry_id]:
+ await august_gateway.authenticator.async_send_verification_code()
- _CONFIGURING[DOMAIN] = configurator.request_config(
- NOTIFICATION_TITLE,
- august_configuration_callback,
- description="Please check your {} ({}) and enter the verification "
+ entry_data = config_entry.data
+ login_method = entry_data.get(CONF_LOGIN_METHOD)
+ username = entry_data.get(CONF_USERNAME)
+
+ hass.data[DOMAIN][entry_id][TWO_FA_REVALIDATE] = configurator.async_request_config(
+ f"{DEFAULT_NAME} ({username})",
+ async_august_configuration_validation_callback,
+ description="August must be re-verified. Please check your {} ({}) and enter the verification "
"code below".format(login_method, username),
submit_caption="Verify",
fields=[
- {"id": "verification_code", "name": "Verification code", "type": "string"}
+ {"id": VERIFICATION_CODE_KEY, "name": "Verification code", "type": "string"}
],
)
+ return
-def setup_august(hass, config, api, authenticator, token_refresh_lock):
+async def async_setup_august(hass, config_entry, august_gateway):
"""Set up the August component."""
- authentication = None
+ entry_id = config_entry.entry_id
+ hass.data[DOMAIN].setdefault(entry_id, {})
+
try:
- authentication = authenticator.authenticate()
- except RequestException as ex:
- _LOGGER.error("Unable to connect to August service: %s", str(ex))
-
- hass.components.persistent_notification.create(
- "Error: {}
"
- "You will need to restart hass after fixing."
- "".format(ex),
- title=NOTIFICATION_TITLE,
- notification_id=NOTIFICATION_ID,
- )
-
- state = authentication.state
-
- if state == AuthenticationState.AUTHENTICATED:
- if DOMAIN in _CONFIGURING:
- hass.components.configurator.request_done(_CONFIGURING.pop(DOMAIN))
-
- hass.data[DATA_AUGUST] = AugustData(
- hass, api, authentication, authenticator, token_refresh_lock
- )
-
- for component in AUGUST_COMPONENTS:
- discovery.load_platform(hass, component, DOMAIN, {}, config)
-
- return True
- if state == AuthenticationState.BAD_PASSWORD:
- _LOGGER.error("Invalid password provided")
+ await august_gateway.async_authenticate()
+ except RequireValidation:
+ await async_request_validation(hass, config_entry, august_gateway)
return False
- if state == AuthenticationState.REQUIRES_VALIDATION:
- request_configuration(hass, config, api, authenticator, token_refresh_lock)
+ except InvalidAuth:
+ _LOGGER.error("Password is no longer valid. Please set up August again")
+ return False
+
+ # We still use the configurator to get a new 2fa code
+ # when needed since config_flow doesn't have a way
+ # to re-request if it expires
+ if TWO_FA_REVALIDATE in hass.data[DOMAIN][entry_id]:
+ hass.components.configurator.async_request_done(
+ hass.data[DOMAIN][entry_id].pop(TWO_FA_REVALIDATE)
+ )
+
+ hass.data[DOMAIN][entry_id][DATA_AUGUST] = AugustData(hass, august_gateway)
+
+ await hass.data[DOMAIN][entry_id][DATA_AUGUST].async_setup()
+
+ for component in AUGUST_COMPONENTS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(config_entry, component)
+ )
+
+ return True
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+ """Set up the August component from YAML."""
+
+ conf = config.get(DOMAIN)
+ hass.data.setdefault(DOMAIN, {})
+
+ if not conf:
return True
- return False
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={
+ CONF_LOGIN_METHOD: conf.get(CONF_LOGIN_METHOD),
+ CONF_USERNAME: conf.get(CONF_USERNAME),
+ CONF_PASSWORD: conf.get(CONF_PASSWORD),
+ CONF_INSTALL_ID: conf.get(CONF_INSTALL_ID),
+ CONF_ACCESS_TOKEN_CACHE_FILE: DEFAULT_AUGUST_CONFIG_FILE,
+ },
+ )
+ )
+ return True
-async def async_setup(hass, config):
- """Set up the August component."""
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Set up August from a config entry."""
+
+ august_gateway = AugustGateway(hass)
- conf = config[DOMAIN]
- api_http_session = None
try:
- api_http_session = Session()
- except RequestException as ex:
- _LOGGER.warning("Creating HTTP session failed with: %s", str(ex))
+ await august_gateway.async_setup(entry.data)
+ return await async_setup_august(hass, entry, august_gateway)
+ except asyncio.TimeoutError:
+ raise ConfigEntryNotReady
- api = Api(timeout=conf.get(CONF_TIMEOUT), http_session=api_http_session)
- authenticator = Authenticator(
- api,
- conf.get(CONF_LOGIN_METHOD),
- conf.get(CONF_USERNAME),
- conf.get(CONF_PASSWORD),
- install_id=conf.get(CONF_INSTALL_ID),
- access_token_cache_file=hass.config.path(AUGUST_CONFIG_FILE),
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Unload a config entry."""
+ unload_ok = all(
+ await asyncio.gather(
+ *[
+ hass.config_entries.async_forward_entry_unload(entry, component)
+ for component in AUGUST_COMPONENTS
+ ]
+ )
)
- def close_http_session(event):
- """Close API sessions used to connect to August."""
- _LOGGER.debug("Closing August HTTP sessions")
- if api_http_session:
- try:
- api_http_session.close()
- except RequestException:
- pass
+ if unload_ok:
+ hass.data[DOMAIN].pop(entry.entry_id)
- _LOGGER.debug("August HTTP session closed.")
-
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_http_session)
- _LOGGER.debug("Registered for Home Assistant stop event")
-
- token_refresh_lock = asyncio.Lock()
-
- return await hass.async_add_executor_job(
- setup_august, hass, config, api, authenticator, token_refresh_lock
- )
+ return unload_ok
-class AugustData:
+class AugustData(AugustSubscriberMixin):
"""August data object."""
- def __init__(self, hass, api, authentication, authenticator, token_refresh_lock):
+ def __init__(self, hass, august_gateway):
"""Init August data object."""
+ super().__init__(hass, MIN_TIME_BETWEEN_DETAIL_UPDATES)
self._hass = hass
- self._api = api
- self._authenticator = authenticator
- self._access_token = authentication.access_token
- self._access_token_expires = authentication.access_token_expires
-
- self._token_refresh_lock = token_refresh_lock
- self._doorbells = self._api.get_doorbells(self._access_token) or []
- self._locks = self._api.get_operable_locks(self._access_token) or []
+ self._august_gateway = august_gateway
+ self.activity_stream = None
+ self._api = august_gateway.api
+ self._device_detail_by_id = {}
+ self._doorbells_by_id = {}
+ self._locks_by_id = {}
self._house_ids = set()
- for device in self._doorbells + self._locks:
- self._house_ids.add(device.house_id)
- self._doorbell_detail_by_id = {}
- self._door_last_state_update_time_utc_by_id = {}
- self._lock_last_status_update_time_utc_by_id = {}
- self._lock_status_by_id = {}
- self._lock_detail_by_id = {}
- self._door_state_by_id = {}
- self._activities_by_id = {}
+ async def async_setup(self):
+ """Async setup of august device data and activities."""
+ locks = (
+ await self._api.async_get_operable_locks(self._august_gateway.access_token)
+ or []
+ )
+ doorbells = (
+ await self._api.async_get_doorbells(self._august_gateway.access_token) or []
+ )
- # We check the locks right away so we can
- # remove inoperative ones
- self._update_locks_status()
- self._update_locks_detail()
+ self._doorbells_by_id = dict((device.device_id, device) for device in doorbells)
+ self._locks_by_id = dict((device.device_id, device) for device in locks)
+ self._house_ids = set(
+ device.house_id for device in itertools.chain(locks, doorbells)
+ )
- self._filter_inoperative_locks()
+ await self._async_refresh_device_detail_by_ids(
+ [device.device_id for device in itertools.chain(locks, doorbells)]
+ )
- @property
- def house_ids(self):
- """Return a list of house_ids."""
- return self._house_ids
+ # We remove all devices that we are missing
+ # detail as we cannot determine if they are usable.
+ # This also allows us to avoid checking for
+ # detail being None all over the place
+ self._remove_inoperative_locks()
+ self._remove_inoperative_doorbells()
+
+ self.activity_stream = ActivityStream(
+ self._hass, self._api, self._august_gateway, self._house_ids
+ )
+ await self.activity_stream.async_setup()
@property
def doorbells(self):
- """Return a list of doorbells."""
- return self._doorbells
+ """Return a list of py-august Doorbell objects."""
+ return self._doorbells_by_id.values()
@property
def locks(self):
- """Return a list of locks."""
- return self._locks
+ """Return a list of py-august Lock objects."""
+ return self._locks_by_id.values()
- async def _async_refresh_access_token_if_needed(self):
- """Refresh the august access token if needed."""
- if self._authenticator.should_refresh():
- async with self._token_refresh_lock:
- await self._hass.async_add_executor_job(self._refresh_access_token)
+ def get_device_detail(self, device_id):
+ """Return the py-august LockDetail or DoorbellDetail object for a device."""
+ return self._device_detail_by_id[device_id]
- def _refresh_access_token(self):
- refreshed_authentication = self._authenticator.refresh_access_token(force=False)
- _LOGGER.info(
- "Refreshed august access token. The old token expired at %s, and the new token expires at %s",
- self._access_token_expires,
- refreshed_authentication.access_token_expires,
- )
- self._access_token = refreshed_authentication.access_token
- self._access_token_expires = refreshed_authentication.access_token_expires
+ async def _async_refresh(self, time):
+ await self._async_refresh_device_detail_by_ids(self._subscriptions.keys())
- async def async_get_device_activities(self, device_id, *activity_types):
- """Return a list of activities."""
- _LOGGER.debug("Getting device activities for %s", device_id)
- await self._async_update_device_activities()
-
- activities = self._activities_by_id.get(device_id, [])
- if activity_types:
- return [a for a in activities if a.activity_type in activity_types]
- return activities
-
- async def async_get_latest_device_activity(self, device_id, *activity_types):
- """Return latest activity."""
- activities = await self.async_get_device_activities(device_id, *activity_types)
- return next(iter(activities or []), None)
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- async def _async_update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT):
- """Update data object with latest from August API."""
-
- # This is the only place we refresh the api token
- await self._async_refresh_access_token_if_needed()
- return await self._hass.async_add_executor_job(
- partial(self._update_device_activities, limit=ACTIVITY_FETCH_LIMIT)
- )
-
- def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT):
- _LOGGER.debug("Start retrieving device activities")
- for house_id in self.house_ids:
- _LOGGER.debug("Updating device activity for house id %s", house_id)
-
- activities = self._api.get_house_activities(
- self._access_token, house_id, limit=limit
+ async def _async_refresh_device_detail_by_ids(self, device_ids_list):
+ for device_id in device_ids_list:
+ if device_id in self._locks_by_id:
+ await self._async_update_device_detail(
+ self._locks_by_id[device_id], self._api.async_get_lock_detail
+ )
+ elif device_id in self._doorbells_by_id:
+ await self._async_update_device_detail(
+ self._doorbells_by_id[device_id],
+ self._api.async_get_doorbell_detail,
+ )
+ _LOGGER.debug(
+ "async_signal_device_id_update (from detail updates): %s", device_id,
)
+ self.async_signal_device_id_update(device_id)
- device_ids = {a.device_id for a in activities}
- for device_id in device_ids:
- self._activities_by_id[device_id] = [
- a for a in activities if a.device_id == device_id
- ]
+ async def _async_update_device_detail(self, device, api_call):
+ _LOGGER.debug(
+ "Started retrieving detail for %s (%s)",
+ device.device_name,
+ device.device_id,
+ )
- _LOGGER.debug("Completed retrieving device activities")
+ try:
+ self._device_detail_by_id[device.device_id] = await api_call(
+ self._august_gateway.access_token, device.device_id
+ )
+ except ClientError as ex:
+ _LOGGER.error(
+ "Request error trying to retrieve %s details for %s. %s",
+ device.device_id,
+ device.device_name,
+ ex,
+ )
+ _LOGGER.debug(
+ "Completed retrieving detail for %s (%s)",
+ device.device_name,
+ device.device_id,
+ )
- async def async_get_doorbell_detail(self, doorbell_id):
- """Return doorbell detail."""
- await self._async_update_doorbells()
- return self._doorbell_detail_by_id.get(doorbell_id)
+ def _get_device_name(self, device_id):
+ """Return doorbell or lock name as August has it stored."""
+ if self._locks_by_id.get(device_id):
+ return self._locks_by_id[device_id].device_name
+ if self._doorbells_by_id.get(device_id):
+ return self._doorbells_by_id[device_id].device_name
- @Throttle(MIN_TIME_BETWEEN_DOORBELL_STATUS_UPDATES)
- async def _async_update_doorbells(self):
- await self._hass.async_add_executor_job(self._update_doorbells)
-
- def _update_doorbells(self):
- detail_by_id = {}
-
- _LOGGER.debug("Start retrieving doorbell details")
- for doorbell in self._doorbells:
- _LOGGER.debug("Updating doorbell status for %s", doorbell.device_name)
- try:
- detail_by_id[doorbell.device_id] = self._api.get_doorbell_detail(
- self._access_token, doorbell.device_id
- )
- except RequestException as ex:
- _LOGGER.error(
- "Request error trying to retrieve doorbell status for %s. %s",
- doorbell.device_name,
- ex,
- )
- detail_by_id[doorbell.device_id] = None
- except Exception:
- detail_by_id[doorbell.device_id] = None
- raise
-
- _LOGGER.debug("Completed retrieving doorbell details")
- self._doorbell_detail_by_id = detail_by_id
-
- def update_door_state(self, lock_id, door_state, update_start_time_utc):
- """Set the door status and last status update time.
-
- This is called when newer activity is detected on the activity feed
- in order to keep the internal data in sync
- """
- self._door_state_by_id[lock_id] = door_state
- self._door_last_state_update_time_utc_by_id[lock_id] = update_start_time_utc
- return True
-
- def update_lock_status(self, lock_id, lock_status, update_start_time_utc):
- """Set the lock status and last status update time.
-
- This is used when the lock, unlock apis are called
- or newer activity is detected on the activity feed
- in order to keep the internal data in sync
- """
- self._lock_status_by_id[lock_id] = lock_status
- self._lock_last_status_update_time_utc_by_id[lock_id] = update_start_time_utc
- return True
-
- def lock_has_doorsense(self, lock_id):
- """Determine if a lock has doorsense installed and can tell when the door is open or closed."""
- # We do not update here since this is not expected
- # to change until restart
- if self._lock_detail_by_id[lock_id] is None:
- return False
- return self._lock_detail_by_id[lock_id].doorsense
-
- async def async_get_lock_status(self, lock_id):
- """Return status if the door is locked or unlocked.
-
- This is status for the lock itself.
- """
- await self._async_update_locks()
- return self._lock_status_by_id.get(lock_id)
-
- async def async_get_lock_detail(self, lock_id):
- """Return lock detail."""
- await self._async_update_locks()
- return self._lock_detail_by_id.get(lock_id)
-
- def get_lock_name(self, device_id):
- """Return lock name as August has it stored."""
- for lock in self._locks:
- if lock.device_id == device_id:
- return lock.device_name
-
- async def async_get_door_state(self, lock_id):
- """Return status if the door is open or closed.
-
- This is the status from the door sensor.
- """
- await self._async_update_locks_status()
- return self._door_state_by_id.get(lock_id)
-
- async def _async_update_locks(self):
- await self._async_update_locks_status()
- await self._async_update_locks_detail()
-
- @Throttle(MIN_TIME_BETWEEN_LOCK_STATUS_UPDATES)
- async def _async_update_locks_status(self):
- await self._hass.async_add_executor_job(self._update_locks_status)
-
- def _update_locks_status(self):
- status_by_id = {}
- state_by_id = {}
- lock_last_status_update_by_id = {}
- door_last_state_update_by_id = {}
-
- _LOGGER.debug("Start retrieving lock and door status")
- for lock in self._locks:
- update_start_time_utc = dt.utcnow()
- _LOGGER.debug("Updating lock and door status for %s", lock.device_name)
- try:
- (
- status_by_id[lock.device_id],
- state_by_id[lock.device_id],
- ) = self._api.get_lock_status(
- self._access_token, lock.device_id, door_status=True
- )
- # Since there is a a race condition between calling the
- # lock and activity apis, we set the last update time
- # BEFORE making the api call since we will compare this
- # to activity later we want activity to win over stale lock/door
- # state.
- lock_last_status_update_by_id[lock.device_id] = update_start_time_utc
- door_last_state_update_by_id[lock.device_id] = update_start_time_utc
- except RequestException as ex:
- _LOGGER.error(
- "Request error trying to retrieve lock and door status for %s. %s",
- lock.device_name,
- ex,
- )
- status_by_id[lock.device_id] = None
- state_by_id[lock.device_id] = None
- except Exception:
- status_by_id[lock.device_id] = None
- state_by_id[lock.device_id] = None
- raise
-
- _LOGGER.debug("Completed retrieving lock and door status")
- self._lock_status_by_id = status_by_id
- self._door_state_by_id = state_by_id
- self._door_last_state_update_time_utc_by_id = door_last_state_update_by_id
- self._lock_last_status_update_time_utc_by_id = lock_last_status_update_by_id
-
- def get_last_lock_status_update_time_utc(self, lock_id):
- """Return the last time that a lock status update was seen from the august API."""
- # Since the activity api is called more frequently than
- # the lock api it is possible that the lock has not
- # been updated yet
- if lock_id not in self._lock_last_status_update_time_utc_by_id:
- return dt.utc_from_timestamp(0)
-
- return self._lock_last_status_update_time_utc_by_id[lock_id]
-
- def get_last_door_state_update_time_utc(self, lock_id):
- """Return the last time that a door status update was seen from the august API."""
- # Since the activity api is called more frequently than
- # the lock api it is possible that the door has not
- # been updated yet
- if lock_id not in self._door_last_state_update_time_utc_by_id:
- return dt.utc_from_timestamp(0)
-
- return self._door_last_state_update_time_utc_by_id[lock_id]
-
- @Throttle(MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES)
- async def _async_update_locks_detail(self):
- await self._hass.async_add_executor_job(self._update_locks_detail)
-
- def _update_locks_detail(self):
- detail_by_id = {}
-
- _LOGGER.debug("Start retrieving locks detail")
- for lock in self._locks:
- try:
- detail_by_id[lock.device_id] = self._api.get_lock_detail(
- self._access_token, lock.device_id
- )
- except RequestException as ex:
- _LOGGER.error(
- "Request error trying to retrieve door details for %s. %s",
- lock.device_name,
- ex,
- )
- detail_by_id[lock.device_id] = None
- except Exception:
- detail_by_id[lock.device_id] = None
- raise
-
- _LOGGER.debug("Completed retrieving locks detail")
- self._lock_detail_by_id = detail_by_id
-
- def lock(self, device_id):
+ async def async_lock(self, device_id):
"""Lock the device."""
- return _call_api_operation_that_requires_bridge(
- self.get_lock_name(device_id),
- "lock",
- self._api.lock,
- self._access_token,
+ return await self._async_call_api_op_requires_bridge(
+ device_id,
+ self._api.async_lock_return_activities,
+ self._august_gateway.access_token,
device_id,
)
- def unlock(self, device_id):
+ async def async_unlock(self, device_id):
"""Unlock the device."""
- return _call_api_operation_that_requires_bridge(
- self.get_lock_name(device_id),
- "unlock",
- self._api.unlock,
- self._access_token,
+ return await self._async_call_api_op_requires_bridge(
+ device_id,
+ self._api.async_unlock_return_activities,
+ self._august_gateway.access_token,
device_id,
)
- def _filter_inoperative_locks(self):
+ async def _async_call_api_op_requires_bridge(
+ self, device_id, func, *args, **kwargs
+ ):
+ """Call an API that requires the bridge to be online and will change the device state."""
+ ret = None
+ try:
+ ret = await func(*args, **kwargs)
+ except AugustApiAIOHTTPError as err:
+ device_name = self._get_device_name(device_id)
+ if device_name is None:
+ device_name = f"DeviceID: {device_id}"
+ raise HomeAssistantError(f"{device_name}: {err}")
+
+ return ret
+
+ def _remove_inoperative_doorbells(self):
+ doorbells = list(self.doorbells)
+ for doorbell in doorbells:
+ device_id = doorbell.device_id
+ doorbell_is_operative = False
+ doorbell_detail = self._device_detail_by_id.get(device_id)
+ if doorbell_detail is None:
+ _LOGGER.info(
+ "The doorbell %s could not be setup because the system could not fetch details about the doorbell.",
+ doorbell.device_name,
+ )
+ else:
+ doorbell_is_operative = True
+
+ if not doorbell_is_operative:
+ del self._doorbells_by_id[device_id]
+ del self._device_detail_by_id[device_id]
+
+ def _remove_inoperative_locks(self):
# Remove non-operative locks as there must
# be a bridge (August Connect) for them to
# be usable
- operative_locks = []
- for lock in self._locks:
- lock_detail = self._lock_detail_by_id.get(lock.device_id)
+ locks = list(self.locks)
+
+ for lock in locks:
+ device_id = lock.device_id
+ lock_is_operative = False
+ lock_detail = self._device_detail_by_id.get(device_id)
if lock_detail is None:
_LOGGER.info(
"The lock %s could not be setup because the system could not fetch details about the lock.",
@@ -535,19 +377,8 @@ class AugustData:
lock.device_name,
)
else:
- operative_locks.append(lock)
+ lock_is_operative = True
- self._locks = operative_locks
-
-
-def _call_api_operation_that_requires_bridge(
- device_name, operation_name, func, *args, **kwargs
-):
- """Call an API that requires the bridge to be online."""
- ret = None
- try:
- ret = func(*args, **kwargs)
- except AugustApiHTTPError as err:
- raise HomeAssistantError(device_name + ": " + str(err))
-
- return ret
+ if not lock_is_operative:
+ del self._locks_by_id[device_id]
+ del self._device_detail_by_id[device_id]
diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py
new file mode 100644
index 00000000000..c7a7d68d959
--- /dev/null
+++ b/homeassistant/components/august/activity.py
@@ -0,0 +1,124 @@
+"""Consume the august activity stream."""
+import logging
+
+from aiohttp import ClientError
+
+from homeassistant.util.dt import utcnow
+
+from .const import ACTIVITY_UPDATE_INTERVAL
+from .subscriber import AugustSubscriberMixin
+
+_LOGGER = logging.getLogger(__name__)
+
+ACTIVITY_STREAM_FETCH_LIMIT = 10
+ACTIVITY_CATCH_UP_FETCH_LIMIT = 1000
+
+
+class ActivityStream(AugustSubscriberMixin):
+ """August activity stream handler."""
+
+ def __init__(self, hass, api, august_gateway, house_ids):
+ """Init August activity stream object."""
+ super().__init__(hass, ACTIVITY_UPDATE_INTERVAL)
+ self._hass = hass
+ self._august_gateway = august_gateway
+ self._api = api
+ self._house_ids = house_ids
+ self._latest_activities_by_id_type = {}
+ self._last_update_time = None
+ self._abort_async_track_time_interval = None
+
+ async def async_setup(self):
+ """Token refresh check and catch up the activity stream."""
+ await self._async_refresh(utcnow)
+
+ def get_latest_device_activity(self, device_id, activity_types):
+ """Return latest activity that is one of the acitivty_types."""
+ if device_id not in self._latest_activities_by_id_type:
+ return None
+
+ latest_device_activities = self._latest_activities_by_id_type[device_id]
+ latest_activity = None
+
+ for activity_type in activity_types:
+ if activity_type in latest_device_activities:
+ if (
+ latest_activity is not None
+ and latest_device_activities[activity_type].activity_start_time
+ <= latest_activity.activity_start_time
+ ):
+ continue
+ latest_activity = latest_device_activities[activity_type]
+
+ return latest_activity
+
+ async def _async_refresh(self, time):
+ """Update the activity stream from August."""
+
+ # This is the only place we refresh the api token
+ await self._august_gateway.async_refresh_access_token_if_needed()
+ await self._async_update_device_activities(time)
+
+ async def _async_update_device_activities(self, time):
+ _LOGGER.debug("Start retrieving device activities")
+
+ limit = (
+ ACTIVITY_STREAM_FETCH_LIMIT
+ if self._last_update_time
+ else ACTIVITY_CATCH_UP_FETCH_LIMIT
+ )
+
+ for house_id in self._house_ids:
+ _LOGGER.debug("Updating device activity for house id %s", house_id)
+ try:
+ activities = await self._api.async_get_house_activities(
+ self._august_gateway.access_token, house_id, limit=limit
+ )
+ except ClientError as ex:
+ _LOGGER.error(
+ "Request error trying to retrieve activity for house id %s: %s",
+ house_id,
+ ex,
+ )
+ # Make sure we process the next house if one of them fails
+ continue
+
+ _LOGGER.debug(
+ "Completed retrieving device activities for house id %s", house_id
+ )
+
+ updated_device_ids = self._process_newer_device_activities(activities)
+
+ if updated_device_ids:
+ for device_id in updated_device_ids:
+ _LOGGER.debug(
+ "async_signal_device_id_update (from activity stream): %s",
+ device_id,
+ )
+ self.async_signal_device_id_update(device_id)
+
+ self._last_update_time = time
+
+ def _process_newer_device_activities(self, activities):
+ updated_device_ids = set()
+ for activity in activities:
+ self._latest_activities_by_id_type.setdefault(activity.device_id, {})
+
+ lastest_activity = self._latest_activities_by_id_type[
+ activity.device_id
+ ].get(activity.activity_type)
+
+ # Ignore activities that are older than the latest one
+ if (
+ lastest_activity
+ and lastest_activity.activity_start_time >= activity.activity_start_time
+ ):
+ continue
+
+ self._latest_activities_by_id_type[activity.device_id][
+ activity.activity_type
+ ] = activity
+
+ updated_device_ids.add(activity.device_id)
+
+ return updated_device_ids
diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py
index aed1995d592..e61de730302 100644
--- a/homeassistant/components/august/binary_sensor.py
+++ b/homeassistant/components/august/binary_sensor.py
@@ -2,56 +2,61 @@
from datetime import datetime, timedelta
import logging
-from august.activity import ACTIVITY_ACTION_STATES, ActivityType
+from august.activity import ActivityType
from august.lock import LockDoorStatus
+from august.util import update_lock_detail_from_activity
-from homeassistant.components.binary_sensor import BinarySensorDevice
-from homeassistant.util import dt
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASS_CONNECTIVITY,
+ DEVICE_CLASS_DOOR,
+ DEVICE_CLASS_MOTION,
+ DEVICE_CLASS_OCCUPANCY,
+ BinarySensorDevice,
+)
+from homeassistant.core import callback
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.util.dt import utcnow
-from . import DATA_AUGUST
+from .const import DATA_AUGUST, DOMAIN
+from .entity import AugustEntityMixin
_LOGGER = logging.getLogger(__name__)
-SCAN_INTERVAL = timedelta(seconds=5)
+TIME_TO_DECLARE_DETECTION = timedelta(seconds=60)
-async def _async_retrieve_door_state(data, lock):
- """Get the latest state of the DoorSense sensor."""
- return await data.async_get_door_state(lock.device_id)
-
-
-async def _async_retrieve_online_state(data, doorbell):
+def _retrieve_online_state(data, detail):
"""Get the latest state of the sensor."""
- detail = await data.async_get_doorbell_detail(doorbell.device_id)
- if detail is None:
- return None
+ # The doorbell will go into standby mode when there is no motion
+ # for a short while. It will wake by itself when needed so we need
+ # to consider is available or we will not report motion or dings
- return detail.is_online
+ return detail.is_online or detail.is_standby
-async def _async_retrieve_motion_state(data, doorbell):
+def _retrieve_motion_state(data, detail):
- return await _async_activity_time_based_state(
- data, doorbell, [ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_DING]
+ return _activity_time_based_state(
+ data,
+ detail.device_id,
+ [ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_DING],
)
-async def _async_retrieve_ding_state(data, doorbell):
+def _retrieve_ding_state(data, detail):
- return await _async_activity_time_based_state(
- data, doorbell, [ActivityType.DOORBELL_DING]
+ return _activity_time_based_state(
+ data, detail.device_id, [ActivityType.DOORBELL_DING]
)
-async def _async_activity_time_based_state(data, doorbell, activity_types):
+def _activity_time_based_state(data, device_id, activity_types):
"""Get the latest state of the sensor."""
- latest = await data.async_get_latest_device_activity(
- doorbell.device_id, *activity_types
- )
+ latest = data.activity_stream.get_latest_device_activity(device_id, activity_types)
if latest is not None:
start = latest.activity_start_time
- end = latest.activity_end_time + timedelta(seconds=45)
+ end = latest.activity_end_time + TIME_TO_DECLARE_DETECTION
return start <= datetime.now() <= end
return None
@@ -59,38 +64,37 @@ async def _async_activity_time_based_state(data, doorbell, activity_types):
SENSOR_NAME = 0
SENSOR_DEVICE_CLASS = 1
SENSOR_STATE_PROVIDER = 2
+SENSOR_STATE_IS_TIME_BASED = 3
-# sensor_type: [name, device_class, async_state_provider]
-SENSOR_TYPES_DOOR = {"door_open": ["Open", "door", _async_retrieve_door_state]}
-
+# sensor_type: [name, device_class, state_provider, is_time_based]
SENSOR_TYPES_DOORBELL = {
- "doorbell_ding": ["Ding", "occupancy", _async_retrieve_ding_state],
- "doorbell_motion": ["Motion", "motion", _async_retrieve_motion_state],
- "doorbell_online": ["Online", "connectivity", _async_retrieve_online_state],
+ "doorbell_ding": ["Ding", DEVICE_CLASS_OCCUPANCY, _retrieve_ding_state, True],
+ "doorbell_motion": ["Motion", DEVICE_CLASS_MOTION, _retrieve_motion_state, True],
+ "doorbell_online": [
+ "Online",
+ DEVICE_CLASS_CONNECTIVITY,
+ _retrieve_online_state,
+ False,
+ ],
}
-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 August binary sensors."""
- data = hass.data[DATA_AUGUST]
+ data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST]
devices = []
for door in data.locks:
- for sensor_type in SENSOR_TYPES_DOOR:
- if not data.lock_has_doorsense(door.device_id):
- _LOGGER.debug(
- "Not adding sensor class %s for lock %s ",
- SENSOR_TYPES_DOOR[sensor_type][SENSOR_DEVICE_CLASS],
- door.device_name,
- )
- continue
-
+ detail = data.get_device_detail(door.device_id)
+ if not detail.doorsense:
_LOGGER.debug(
- "Adding sensor class %s for %s",
- SENSOR_TYPES_DOOR[sensor_type][SENSOR_DEVICE_CLASS],
+ "Not adding sensor class door for lock %s because it does not have doorsense.",
door.device_name,
)
- devices.append(AugustDoorBinarySensor(data, sensor_type, door))
+ continue
+
+ _LOGGER.debug("Adding sensor class door for %s", door.device_name)
+ devices.append(AugustDoorBinarySensor(data, "door_open", door))
for doorbell in data.doorbells:
for sensor_type in SENSOR_TYPES_DOORBELL:
@@ -104,116 +108,66 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
async_add_entities(devices, True)
-class AugustDoorBinarySensor(BinarySensorDevice):
+class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorDevice):
"""Representation of an August Door binary sensor."""
- def __init__(self, data, sensor_type, door):
+ def __init__(self, data, sensor_type, device):
"""Initialize the sensor."""
+ super().__init__(data, device)
self._data = data
self._sensor_type = sensor_type
- self._door = door
- self._state = None
- self._available = False
+ self._device = device
+ self._update_from_data()
@property
def available(self):
"""Return the availability of this sensor."""
- return self._available
+ return self._detail.bridge_is_online
@property
def is_on(self):
"""Return true if the binary sensor is on."""
- return self._state
+ return self._detail.door_state == LockDoorStatus.OPEN
@property
def device_class(self):
- """Return the class of this device, from component DEVICE_CLASSES."""
- return SENSOR_TYPES_DOOR[self._sensor_type][SENSOR_DEVICE_CLASS]
+ """Return the class of this device."""
+ return DEVICE_CLASS_DOOR
@property
def name(self):
"""Return the name of the binary sensor."""
- return "{} {}".format(
- self._door.device_name, SENSOR_TYPES_DOOR[self._sensor_type][SENSOR_NAME]
- )
+ return f"{self._device.device_name} Open"
- async def async_update(self):
+ @callback
+ def _update_from_data(self):
"""Get the latest state of the sensor and update activity."""
- async_state_provider = SENSOR_TYPES_DOOR[self._sensor_type][
- SENSOR_STATE_PROVIDER
- ]
- lock_door_state = await async_state_provider(self._data, self._door)
- self._available = (
- lock_door_state is not None and lock_door_state != LockDoorStatus.UNKNOWN
- )
- self._state = lock_door_state == LockDoorStatus.OPEN
-
- door_activity = await self._data.async_get_latest_device_activity(
- self._door.device_id, ActivityType.DOOR_OPERATION
+ door_activity = self._data.activity_stream.get_latest_device_activity(
+ self._device_id, [ActivityType.DOOR_OPERATION]
)
if door_activity is not None:
- self._sync_door_activity(door_activity)
-
- def _update_door_state(self, door_state, update_start_time):
- new_state = door_state == LockDoorStatus.OPEN
- if self._state != new_state:
- self._state = new_state
- self._data.update_door_state(
- self._door.device_id, door_state, update_start_time
- )
-
- def _sync_door_activity(self, door_activity):
- """Check the activity for the latest door open/close activity (events).
-
- We use this to determine the door state in between calls to the lock
- api as we update it more frequently
- """
- last_door_state_update_time_utc = self._data.get_last_door_state_update_time_utc(
- self._door.device_id
- )
- activity_end_time_utc = dt.as_utc(door_activity.activity_end_time)
-
- if activity_end_time_utc > last_door_state_update_time_utc:
- _LOGGER.debug(
- "The activity log has new events for %s: [action=%s] [activity_end_time_utc=%s] > [last_door_state_update_time_utc=%s]",
- self.name,
- door_activity.action,
- activity_end_time_utc,
- last_door_state_update_time_utc,
- )
- activity_start_time_utc = dt.as_utc(door_activity.activity_start_time)
- if door_activity.action in ACTIVITY_ACTION_STATES:
- self._update_door_state(
- ACTIVITY_ACTION_STATES[door_activity.action],
- activity_start_time_utc,
- )
- else:
- _LOGGER.info(
- "Unhandled door activity action %s for %s",
- door_activity.action,
- self.name,
- )
+ update_lock_detail_from_activity(self._detail, door_activity)
@property
def unique_id(self) -> str:
"""Get the unique of the door open binary sensor."""
- return "{:s}_{:s}".format(
- self._door.device_id,
- SENSOR_TYPES_DOOR[self._sensor_type][SENSOR_NAME].lower(),
- )
+ return f"{self._device_id}_open"
-class AugustDoorbellBinarySensor(BinarySensorDevice):
+class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorDevice):
"""Representation of an August binary sensor."""
- def __init__(self, data, sensor_type, doorbell):
+ def __init__(self, data, sensor_type, device):
"""Initialize the sensor."""
+ super().__init__(data, device)
+ self._check_for_off_update_listener = None
self._data = data
self._sensor_type = sensor_type
- self._doorbell = doorbell
+ self._device = device
self._state = None
self._available = False
+ self._update_from_data()
@property
def available(self):
@@ -233,26 +187,68 @@ class AugustDoorbellBinarySensor(BinarySensorDevice):
@property
def name(self):
"""Return the name of the binary sensor."""
- return "{} {}".format(
- self._doorbell.device_name,
- SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME],
+ return f"{self._device.device_name} {SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME]}"
+
+ @property
+ def _state_provider(self):
+ """Return the state provider for the binary sensor."""
+ return SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_STATE_PROVIDER]
+
+ @property
+ def _is_time_based(self):
+ """Return true of false if the sensor is time based."""
+ return SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_STATE_IS_TIME_BASED]
+
+ @callback
+ def _update_from_data(self):
+ """Get the latest state of the sensor."""
+ self._cancel_any_pending_updates()
+ self._state = self._state_provider(self._data, self._detail)
+
+ if self._is_time_based:
+ self._available = _retrieve_online_state(self._data, self._detail)
+ self._schedule_update_to_recheck_turn_off_sensor()
+ else:
+ self._available = True
+
+ def _schedule_update_to_recheck_turn_off_sensor(self):
+ """Schedule an update to recheck the sensor to see if it is ready to turn off."""
+
+ # If the sensor is already off there is nothing to do
+ if not self._state:
+ return
+
+ # self.hass is only available after setup is completed
+ # and we will recheck in async_added_to_hass
+ if not self.hass:
+ return
+
+ @callback
+ def _scheduled_update(now):
+ """Timer callback for sensor update."""
+ self._check_for_off_update_listener = None
+ self._update_from_data()
+
+ self._check_for_off_update_listener = async_track_point_in_utc_time(
+ self.hass, _scheduled_update, utcnow() + TIME_TO_DECLARE_DETECTION
)
- async def async_update(self):
- """Get the latest state of the sensor."""
- async_state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][
- SENSOR_STATE_PROVIDER
- ]
- self._state = await async_state_provider(self._data, self._doorbell)
- # The doorbell will go into standby mode when there is no motion
- # for a short while. It will wake by itself when needed so we need
- # to consider is available or we will not report motion or dings
- self._available = self._doorbell.is_online or self._doorbell.status == "standby"
+ def _cancel_any_pending_updates(self):
+ """Cancel any updates to recheck a sensor to see if it is ready to turn off."""
+ if self._check_for_off_update_listener:
+ _LOGGER.debug("%s: canceled pending update", self.entity_id)
+ self._check_for_off_update_listener()
+ self._check_for_off_update_listener = None
+
+ async def async_added_to_hass(self):
+ """Call the mixin to subscribe and setup an async_track_point_in_utc_time to turn off the sensor if needed."""
+ self._schedule_update_to_recheck_turn_off_sensor()
+ await super().async_added_to_hass()
@property
def unique_id(self) -> str:
"""Get the unique id of the doorbell sensor."""
- return "{:s}_{:s}".format(
- self._doorbell.device_id,
- SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME].lower(),
+ return (
+ f"{self._device_id}_"
+ f"{SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME].lower()}"
)
diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py
index 5426d9574dc..4037489fa22 100644
--- a/homeassistant/components/august/camera.py
+++ b/homeassistant/components/august/camera.py
@@ -1,18 +1,19 @@
-"""Support for August camera."""
-from datetime import timedelta
+"""Support for August doorbell camera."""
-import requests
+from august.activity import ActivityType
+from august.util import update_doorbell_image_from_activity
from homeassistant.components.camera import Camera
+from homeassistant.core import callback
+from homeassistant.helpers import aiohttp_client
-from . import DATA_AUGUST, DEFAULT_TIMEOUT
-
-SCAN_INTERVAL = timedelta(seconds=10)
+from .const import DATA_AUGUST, DEFAULT_NAME, DEFAULT_TIMEOUT, DOMAIN
+from .entity import AugustEntityMixin
-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 August cameras."""
- data = hass.data[DATA_AUGUST]
+ data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST]
devices = []
for doorbell in data.doorbells:
@@ -21,14 +22,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
async_add_entities(devices, True)
-class AugustCamera(Camera):
+class AugustCamera(AugustEntityMixin, Camera):
"""An implementation of a August security camera."""
- def __init__(self, data, doorbell, timeout):
+ def __init__(self, data, device, timeout):
"""Initialize a August security camera."""
- super().__init__()
+ super().__init__(data, device)
self._data = data
- self._doorbell = doorbell
+ self._device = device
self._timeout = timeout
self._image_url = None
self._image_content = None
@@ -36,12 +37,12 @@ class AugustCamera(Camera):
@property
def name(self):
"""Return the name of this device."""
- return self._doorbell.device_name
+ return f"{self._device.device_name} Camera"
@property
def is_recording(self):
"""Return true if the device is recording."""
- return self._doorbell.has_subscription
+ return self._device.has_subscription
@property
def motion_detection_enabled(self):
@@ -51,31 +52,35 @@ class AugustCamera(Camera):
@property
def brand(self):
"""Return the camera brand."""
- return "August"
+ return DEFAULT_NAME
@property
def model(self):
"""Return the camera model."""
- return "Doorbell"
+ return self._detail.model
+
+ @callback
+ def _update_from_data(self):
+ """Get the latest state of the sensor."""
+ doorbell_activity = self._data.activity_stream.get_latest_device_activity(
+ self._device_id, [ActivityType.DOORBELL_MOTION]
+ )
+
+ if doorbell_activity is not None:
+ update_doorbell_image_from_activity(self._detail, doorbell_activity)
async def async_camera_image(self):
"""Return bytes of camera image."""
- latest = await self._data.async_get_doorbell_detail(self._doorbell.device_id)
+ self._update_from_data()
- if self._image_url is not latest.image_url:
- self._image_url = latest.image_url
- self._image_content = await self.hass.async_add_executor_job(
- self._camera_image
+ if self._image_url is not self._detail.image_url:
+ self._image_url = self._detail.image_url
+ self._image_content = await self._detail.async_get_doorbell_image(
+ aiohttp_client.async_get_clientsession(self.hass), timeout=self._timeout
)
-
return self._image_content
- def _camera_image(self):
- """Return bytes of camera image via http get."""
- # Move this to py-august: see issue#32048
- return requests.get(self._image_url, timeout=self._timeout).content
-
@property
def unique_id(self) -> str:
"""Get the unique id of the camera."""
- return f"{self._doorbell.device_id:s}_camera"
+ return f"{self._device_id:s}_camera"
diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py
new file mode 100644
index 00000000000..acdfb1d4b63
--- /dev/null
+++ b/homeassistant/components/august/config_flow.py
@@ -0,0 +1,133 @@
+"""Config flow for August integration."""
+import logging
+
+from august.authenticator import ValidationResult
+import voluptuous as vol
+
+from homeassistant import config_entries, core
+from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
+
+from .const import (
+ CONF_LOGIN_METHOD,
+ DEFAULT_TIMEOUT,
+ LOGIN_METHODS,
+ VERIFICATION_CODE_KEY,
+)
+from .const import DOMAIN # pylint:disable=unused-import
+from .exceptions import CannotConnect, InvalidAuth, RequireValidation
+from .gateway import AugustGateway
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_LOGIN_METHOD, default="phone"): vol.In(LOGIN_METHODS),
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): str,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int),
+ }
+)
+
+
+async def async_validate_input(
+ hass: core.HomeAssistant, data, august_gateway,
+):
+ """Validate the user input allows us to connect.
+
+ Data has the keys from DATA_SCHEMA with values provided by the user.
+
+ Request configuration steps from the user.
+ """
+
+ code = data.get(VERIFICATION_CODE_KEY)
+
+ if code is not None:
+ result = await august_gateway.authenticator.async_validate_verification_code(
+ code
+ )
+ _LOGGER.debug("Verification code validation: %s", result)
+ if result != ValidationResult.VALIDATED:
+ raise RequireValidation
+
+ try:
+ await august_gateway.async_authenticate()
+ except RequireValidation:
+ _LOGGER.debug(
+ "Requesting new verification code for %s via %s",
+ data.get(CONF_USERNAME),
+ data.get(CONF_LOGIN_METHOD),
+ )
+ if code is None:
+ await august_gateway.authenticator.async_send_verification_code()
+ raise
+
+ return {
+ "title": data.get(CONF_USERNAME),
+ "data": august_gateway.config_entry(),
+ }
+
+
+class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for August."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ def __init__(self):
+ """Store an AugustGateway()."""
+ self._august_gateway = None
+ self.user_auth_details = {}
+ super().__init__()
+
+ async def async_step_user(self, user_input=None):
+ """Handle the initial step."""
+ if self._august_gateway is None:
+ self._august_gateway = AugustGateway(self.hass)
+ errors = {}
+ if user_input is not None:
+ await self._august_gateway.async_setup(user_input)
+
+ try:
+ info = await async_validate_input(
+ self.hass, user_input, self._august_gateway,
+ )
+ await self.async_set_unique_id(user_input[CONF_USERNAME])
+ return self.async_create_entry(title=info["title"], data=info["data"])
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except RequireValidation:
+ self.user_auth_details = user_input
+
+ return await self.async_step_validation()
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ return self.async_show_form(
+ step_id="user", data_schema=DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_validation(self, user_input=None):
+ """Handle validation (2fa) step."""
+ if user_input:
+ return await self.async_step_user({**self.user_auth_details, **user_input})
+
+ return self.async_show_form(
+ step_id="validation",
+ data_schema=vol.Schema(
+ {vol.Required(VERIFICATION_CODE_KEY): vol.All(str, vol.Strip)}
+ ),
+ description_placeholders={
+ CONF_USERNAME: self.user_auth_details.get(CONF_USERNAME),
+ CONF_LOGIN_METHOD: self.user_auth_details.get(CONF_LOGIN_METHOD),
+ },
+ )
+
+ async def async_step_import(self, user_input):
+ """Handle import."""
+ await self.async_set_unique_id(user_input[CONF_USERNAME])
+ self._abort_if_unique_id_configured()
+
+ return await self.async_step_user(user_input)
diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py
new file mode 100644
index 00000000000..e8b8637b6cb
--- /dev/null
+++ b/homeassistant/components/august/const.py
@@ -0,0 +1,44 @@
+"""Constants for August devices."""
+
+from datetime import timedelta
+
+DEFAULT_TIMEOUT = 10
+
+CONF_ACCESS_TOKEN_CACHE_FILE = "access_token_cache_file"
+CONF_LOGIN_METHOD = "login_method"
+CONF_INSTALL_ID = "install_id"
+
+VERIFICATION_CODE_KEY = "verification_code"
+
+NOTIFICATION_ID = "august_notification"
+NOTIFICATION_TITLE = "August"
+
+DEFAULT_AUGUST_CONFIG_FILE = ".august.conf"
+
+DATA_AUGUST = "data_august"
+
+DEFAULT_NAME = "August"
+DOMAIN = "august"
+
+OPERATION_METHOD_AUTORELOCK = "autorelock"
+OPERATION_METHOD_REMOTE = "remote"
+OPERATION_METHOD_KEYPAD = "keypad"
+OPERATION_METHOD_MOBILE_DEVICE = "mobile"
+
+ATTR_OPERATION_AUTORELOCK = "autorelock"
+ATTR_OPERATION_METHOD = "method"
+ATTR_OPERATION_REMOTE = "remote"
+ATTR_OPERATION_KEYPAD = "keypad"
+
+# Limit battery, online, and hardware updates to hourly
+# in order to reduce the number of api requests and
+# avoid hitting rate limits
+MIN_TIME_BETWEEN_DETAIL_UPDATES = timedelta(hours=1)
+
+# Activity needs to be checked more frequently as the
+# doorbell motion and rings are included here
+ACTIVITY_UPDATE_INTERVAL = timedelta(seconds=10)
+
+LOGIN_METHODS = ["phone", "email"]
+
+AUGUST_COMPONENTS = ["camera", "binary_sensor", "lock", "sensor"]
diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py
new file mode 100644
index 00000000000..a3f72da44be
--- /dev/null
+++ b/homeassistant/components/august/entity.py
@@ -0,0 +1,67 @@
+"""Base class for August entity."""
+
+import logging
+
+from homeassistant.core import callback
+from homeassistant.helpers.entity import Entity
+
+from . import DEFAULT_NAME, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class AugustEntityMixin(Entity):
+ """Base implementation for August device."""
+
+ def __init__(self, data, device):
+ """Initialize an August device."""
+ super().__init__()
+ self._data = data
+ self._device = device
+
+ @property
+ def should_poll(self):
+ """Return False, updates are controlled via the hub."""
+ return False
+
+ @property
+ def _device_id(self):
+ return self._device.device_id
+
+ @property
+ def _detail(self):
+ return self._data.get_device_detail(self._device.device_id)
+
+ @property
+ def device_info(self):
+ """Return the device_info of the device."""
+ return {
+ "identifiers": {(DOMAIN, self._device_id)},
+ "name": self._device.device_name,
+ "manufacturer": DEFAULT_NAME,
+ "sw_version": self._detail.firmware_version,
+ "model": self._detail.model,
+ }
+
+ @callback
+ def _update_from_data_and_write_state(self):
+ self._update_from_data()
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self):
+ """Subscribe to updates."""
+ self._data.async_subscribe_device_id(
+ self._device_id, self._update_from_data_and_write_state
+ )
+ self._data.activity_stream.async_subscribe_device_id(
+ self._device_id, self._update_from_data_and_write_state
+ )
+
+ async def async_will_remove_from_hass(self):
+ """Undo subscription."""
+ self._data.async_unsubscribe_device_id(
+ self._device_id, self._update_from_data_and_write_state
+ )
+ self._data.activity_stream.async_unsubscribe_device_id(
+ self._device_id, self._update_from_data_and_write_state
+ )
diff --git a/homeassistant/components/august/exceptions.py b/homeassistant/components/august/exceptions.py
new file mode 100644
index 00000000000..78c467ab3a1
--- /dev/null
+++ b/homeassistant/components/august/exceptions.py
@@ -0,0 +1,15 @@
+"""Shared excecption for the august integration."""
+
+from homeassistant import exceptions
+
+
+class RequireValidation(exceptions.HomeAssistantError):
+ """Error to indicate we require validation (2fa)."""
+
+
+class CannotConnect(exceptions.HomeAssistantError):
+ """Error to indicate we cannot connect."""
+
+
+class InvalidAuth(exceptions.HomeAssistantError):
+ """Error to indicate there is invalid auth."""
diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py
new file mode 100644
index 00000000000..bb39523a984
--- /dev/null
+++ b/homeassistant/components/august/gateway.py
@@ -0,0 +1,133 @@
+"""Handle August connection setup and authentication."""
+
+import asyncio
+import logging
+
+from aiohttp import ClientError
+from august.api_async import ApiAsync
+from august.authenticator_async import AuthenticationState, AuthenticatorAsync
+
+from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
+from homeassistant.helpers import aiohttp_client
+
+from .const import (
+ CONF_ACCESS_TOKEN_CACHE_FILE,
+ CONF_INSTALL_ID,
+ CONF_LOGIN_METHOD,
+ DEFAULT_AUGUST_CONFIG_FILE,
+ VERIFICATION_CODE_KEY,
+)
+from .exceptions import CannotConnect, InvalidAuth, RequireValidation
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class AugustGateway:
+ """Handle the connection to August."""
+
+ def __init__(self, hass):
+ """Init the connection."""
+ self._aiohttp_session = aiohttp_client.async_get_clientsession(hass)
+ self._token_refresh_lock = asyncio.Lock()
+ self._access_token_cache_file = None
+ self._hass = hass
+ self._config = None
+ self._api = None
+ self._authenticator = None
+ self._authentication = None
+
+ @property
+ def authenticator(self):
+ """August authentication object from py-august."""
+ return self._authenticator
+
+ @property
+ def authentication(self):
+ """August authentication object from py-august."""
+ return self._authentication
+
+ @property
+ def access_token(self):
+ """Access token for the api."""
+ return self._authentication.access_token
+
+ @property
+ def api(self):
+ """August api object from py-august."""
+ return self._api
+
+ def config_entry(self):
+ """Config entry."""
+ return {
+ CONF_LOGIN_METHOD: self._config[CONF_LOGIN_METHOD],
+ CONF_USERNAME: self._config[CONF_USERNAME],
+ CONF_PASSWORD: self._config[CONF_PASSWORD],
+ CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID),
+ CONF_TIMEOUT: self._config.get(CONF_TIMEOUT),
+ CONF_ACCESS_TOKEN_CACHE_FILE: self._access_token_cache_file,
+ }
+
+ async def async_setup(self, conf):
+ """Create the api and authenticator objects."""
+ if conf.get(VERIFICATION_CODE_KEY):
+ return
+
+ self._access_token_cache_file = conf.get(
+ CONF_ACCESS_TOKEN_CACHE_FILE,
+ f".{conf[CONF_USERNAME]}{DEFAULT_AUGUST_CONFIG_FILE}",
+ )
+ self._config = conf
+
+ self._api = ApiAsync(
+ self._aiohttp_session, timeout=self._config.get(CONF_TIMEOUT)
+ )
+
+ self._authenticator = AuthenticatorAsync(
+ self._api,
+ self._config[CONF_LOGIN_METHOD],
+ self._config[CONF_USERNAME],
+ self._config[CONF_PASSWORD],
+ install_id=self._config.get(CONF_INSTALL_ID),
+ access_token_cache_file=self._hass.config.path(
+ self._access_token_cache_file
+ ),
+ )
+
+ await self._authenticator.async_setup_authentication()
+
+ async def async_authenticate(self):
+ """Authenticate with the details provided to setup."""
+ self._authentication = None
+ try:
+ self._authentication = await self.authenticator.async_authenticate()
+ except ClientError as ex:
+ _LOGGER.error("Unable to connect to August service: %s", str(ex))
+ raise CannotConnect
+
+ if self._authentication.state == AuthenticationState.BAD_PASSWORD:
+ raise InvalidAuth
+
+ if self._authentication.state == AuthenticationState.REQUIRES_VALIDATION:
+ raise RequireValidation
+
+ if self._authentication.state != AuthenticationState.AUTHENTICATED:
+ _LOGGER.error(
+ "Unknown authentication state: %s", self._authentication.state
+ )
+ raise InvalidAuth
+
+ return self._authentication
+
+ async def async_refresh_access_token_if_needed(self):
+ """Refresh the august access token if needed."""
+ if self.authenticator.should_refresh():
+ async with self._token_refresh_lock:
+ refreshed_authentication = await self.authenticator.async_refresh_access_token(
+ force=False
+ )
+ _LOGGER.info(
+ "Refreshed august access token. The old token expired at %s, and the new token expires at %s",
+ self.authentication.access_token_expires,
+ refreshed_authentication.access_token_expires,
+ )
+ self._authentication = refreshed_authentication
diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py
index 9d5df1192a7..495c215edad 100644
--- a/homeassistant/components/august/lock.py
+++ b/homeassistant/components/august/lock.py
@@ -1,24 +1,24 @@
"""Support for August lock."""
-from datetime import timedelta
import logging
-from august.activity import ACTIVITY_ACTION_STATES, ActivityType
+from august.activity import ActivityType
from august.lock import LockStatus
+from august.util import update_lock_detail_from_activity
-from homeassistant.components.lock import LockDevice
+from homeassistant.components.lock import ATTR_CHANGED_BY, LockDevice
from homeassistant.const import ATTR_BATTERY_LEVEL
-from homeassistant.util import dt
+from homeassistant.core import callback
+from homeassistant.helpers.restore_state import RestoreEntity
-from . import DATA_AUGUST
+from .const import DATA_AUGUST, DOMAIN
+from .entity import AugustEntityMixin
_LOGGER = logging.getLogger(__name__)
-SCAN_INTERVAL = timedelta(seconds=10)
-
-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 August locks."""
- data = hass.data[DATA_AUGUST]
+ data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST]
devices = []
for lock in data.locks:
@@ -28,94 +28,64 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
async_add_entities(devices, True)
-class AugustLock(LockDevice):
+class AugustLock(AugustEntityMixin, RestoreEntity, LockDevice):
"""Representation of an August lock."""
- def __init__(self, data, lock):
+ def __init__(self, data, device):
"""Initialize the lock."""
+ super().__init__(data, device)
self._data = data
- self._lock = lock
+ self._device = device
self._lock_status = None
- self._lock_detail = None
self._changed_by = None
self._available = False
+ self._update_from_data()
async def async_lock(self, **kwargs):
"""Lock the device."""
- update_start_time_utc = dt.utcnow()
- lock_status = await self.hass.async_add_executor_job(
- self._data.lock, self._lock.device_id
- )
- self._update_lock_status(lock_status, update_start_time_utc)
+ await self._call_lock_operation(self._data.async_lock)
async def async_unlock(self, **kwargs):
"""Unlock the device."""
- update_start_time_utc = dt.utcnow()
- lock_status = await self.hass.async_add_executor_job(
- self._data.unlock, self._lock.device_id
- )
- self._update_lock_status(lock_status, update_start_time_utc)
+ await self._call_lock_operation(self._data.async_unlock)
- def _update_lock_status(self, lock_status, update_start_time_utc):
- if self._lock_status != lock_status:
- self._lock_status = lock_status
- self._data.update_lock_status(
- self._lock.device_id, lock_status, update_start_time_utc
+ async def _call_lock_operation(self, lock_operation):
+ activities = await lock_operation(self._device_id)
+ for lock_activity in activities:
+ update_lock_detail_from_activity(self._detail, lock_activity)
+
+ if self._update_lock_status_from_detail():
+ _LOGGER.debug(
+ "async_signal_device_id_update (from lock operation): %s",
+ self._device_id,
)
- self.schedule_update_ha_state()
+ self._data.async_signal_device_id_update(self._device_id)
- async def async_update(self):
+ def _update_lock_status_from_detail(self):
+ self._available = self._detail.bridge_is_online
+
+ if self._lock_status != self._detail.lock_status:
+ self._lock_status = self._detail.lock_status
+ return True
+ return False
+
+ @callback
+ def _update_from_data(self):
"""Get the latest state of the sensor and update activity."""
- self._lock_status = await self._data.async_get_lock_status(self._lock.device_id)
- self._available = (
- self._lock_status is not None and self._lock_status != LockStatus.UNKNOWN
- )
- self._lock_detail = await self._data.async_get_lock_detail(self._lock.device_id)
-
- lock_activity = await self._data.async_get_latest_device_activity(
- self._lock.device_id, ActivityType.LOCK_OPERATION
+ lock_activity = self._data.activity_stream.get_latest_device_activity(
+ self._device_id, [ActivityType.LOCK_OPERATION]
)
if lock_activity is not None:
self._changed_by = lock_activity.operated_by
- self._sync_lock_activity(lock_activity)
+ update_lock_detail_from_activity(self._detail, lock_activity)
- def _sync_lock_activity(self, lock_activity):
- """Check the activity for the latest lock/unlock activity (events).
-
- We use this to determine the lock state in between calls to the lock
- api as we update it more frequently
- """
- last_lock_status_update_time_utc = self._data.get_last_lock_status_update_time_utc(
- self._lock.device_id
- )
- activity_end_time_utc = dt.as_utc(lock_activity.activity_end_time)
-
- if activity_end_time_utc > last_lock_status_update_time_utc:
- _LOGGER.debug(
- "The activity log has new events for %s: [action=%s] [activity_end_time_utc=%s] > [last_lock_status_update_time_utc=%s]",
- self.name,
- lock_activity.action,
- activity_end_time_utc,
- last_lock_status_update_time_utc,
- )
- activity_start_time_utc = dt.as_utc(lock_activity.activity_start_time)
- if lock_activity.action in ACTIVITY_ACTION_STATES:
- self._update_lock_status(
- ACTIVITY_ACTION_STATES[lock_activity.action],
- activity_start_time_utc,
- )
- else:
- _LOGGER.info(
- "Unhandled lock activity action %s for %s",
- lock_activity.action,
- self.name,
- )
+ self._update_lock_status_from_detail()
@property
def name(self):
"""Return the name of this device."""
- return self._lock.device_name
+ return self._device.device_name
@property
def available(self):
@@ -125,7 +95,8 @@ class AugustLock(LockDevice):
@property
def is_locked(self):
"""Return true if device is on."""
-
+ if self._lock_status is None or self._lock_status is LockStatus.UNKNOWN:
+ return None
return self._lock_status is LockStatus.LOCKED
@property
@@ -136,17 +107,25 @@ class AugustLock(LockDevice):
@property
def device_state_attributes(self):
"""Return the device specific state attributes."""
- if self._lock_detail is None:
- return None
+ attributes = {ATTR_BATTERY_LEVEL: self._detail.battery_level}
- attributes = {ATTR_BATTERY_LEVEL: self._lock_detail.battery_level}
-
- if self._lock_detail.keypad is not None:
- attributes["keypad_battery_level"] = self._lock_detail.keypad.battery_level
+ if self._detail.keypad is not None:
+ attributes["keypad_battery_level"] = self._detail.keypad.battery_level
return attributes
+ async def async_added_to_hass(self):
+ """Restore ATTR_CHANGED_BY on startup since it is likely no longer in the activity log."""
+ await super().async_added_to_hass()
+
+ last_state = await self.async_get_last_state()
+ if not last_state:
+ return
+
+ if ATTR_CHANGED_BY in last_state.attributes:
+ self._changed_by = last_state.attributes[ATTR_CHANGED_BY]
+
@property
def unique_id(self) -> str:
"""Get the unique id of the lock."""
- return f"{self._lock.device_id:s}_lock"
+ return f"{self._device_id:s}_lock"
diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json
index 7afa742f3ca..f1085b81554 100644
--- a/homeassistant/components/august/manifest.json
+++ b/homeassistant/components/august/manifest.json
@@ -2,7 +2,14 @@
"domain": "august",
"name": "August",
"documentation": "https://www.home-assistant.io/integrations/august",
- "requirements": ["py-august==0.14.0"],
- "dependencies": ["configurator"],
- "codeowners": ["@bdraco"]
+ "requirements": [
+ "py-august==0.25.0"
+ ],
+ "dependencies": [
+ "configurator"
+ ],
+ "codeowners": [
+ "@bdraco"
+ ],
+ "config_flow": true
}
diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py
new file mode 100644
index 00000000000..018837a81dc
--- /dev/null
+++ b/homeassistant/components/august/sensor.py
@@ -0,0 +1,244 @@
+"""Support for August sensors."""
+import logging
+
+from august.activity import ActivityType
+
+from homeassistant.components.sensor import DEVICE_CLASS_BATTERY
+from homeassistant.const import ATTR_ENTITY_PICTURE, UNIT_PERCENTAGE
+from homeassistant.core import callback
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.restore_state import RestoreEntity
+
+from .const import (
+ ATTR_OPERATION_AUTORELOCK,
+ ATTR_OPERATION_KEYPAD,
+ ATTR_OPERATION_METHOD,
+ ATTR_OPERATION_REMOTE,
+ DATA_AUGUST,
+ DOMAIN,
+ OPERATION_METHOD_AUTORELOCK,
+ OPERATION_METHOD_KEYPAD,
+ OPERATION_METHOD_MOBILE_DEVICE,
+ OPERATION_METHOD_REMOTE,
+)
+from .entity import AugustEntityMixin
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def _retrieve_device_battery_state(detail):
+ """Get the latest state of the sensor."""
+ return detail.battery_level
+
+
+def _retrieve_linked_keypad_battery_state(detail):
+ """Get the latest state of the sensor."""
+ if detail.keypad is None:
+ return None
+
+ return detail.keypad.battery_percentage
+
+
+SENSOR_TYPES_BATTERY = {
+ "device_battery": {
+ "name": "Battery",
+ "state_provider": _retrieve_device_battery_state,
+ },
+ "linked_keypad_battery": {
+ "name": "Keypad Battery",
+ "state_provider": _retrieve_linked_keypad_battery_state,
+ },
+}
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the August sensors."""
+ data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST]
+ devices = []
+
+ operation_sensors = []
+ batteries = {
+ "device_battery": [],
+ "linked_keypad_battery": [],
+ }
+ for device in data.doorbells:
+ batteries["device_battery"].append(device)
+ for device in data.locks:
+ batteries["device_battery"].append(device)
+ batteries["linked_keypad_battery"].append(device)
+ operation_sensors.append(device)
+
+ for sensor_type in SENSOR_TYPES_BATTERY:
+ for device in batteries[sensor_type]:
+ state_provider = SENSOR_TYPES_BATTERY[sensor_type]["state_provider"]
+ detail = data.get_device_detail(device.device_id)
+ state = state_provider(detail)
+ sensor_name = SENSOR_TYPES_BATTERY[sensor_type]["name"]
+ if state is None:
+ _LOGGER.debug(
+ "Not adding battery sensor %s for %s because it is not present",
+ sensor_name,
+ device.device_name,
+ )
+ else:
+ _LOGGER.debug(
+ "Adding battery sensor %s for %s", sensor_name, device.device_name,
+ )
+ devices.append(AugustBatterySensor(data, sensor_type, device))
+
+ for device in operation_sensors:
+ devices.append(AugustOperatorSensor(data, device))
+
+ async_add_entities(devices, True)
+
+
+class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, Entity):
+ """Representation of an August lock operation sensor."""
+
+ def __init__(self, data, device):
+ """Initialize the sensor."""
+ super().__init__(data, device)
+ self._data = data
+ self._device = device
+ self._state = None
+ self._operated_remote = None
+ self._operated_keypad = None
+ self._operated_autorelock = None
+ self._operated_time = None
+ self._available = False
+ self._entity_picture = None
+ self._update_from_data()
+
+ @property
+ def available(self):
+ """Return the availability of this sensor."""
+ return self._available
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return f"{self._device.device_name} Operator"
+
+ @callback
+ def _update_from_data(self):
+ """Get the latest state of the sensor and update activity."""
+ lock_activity = self._data.activity_stream.get_latest_device_activity(
+ self._device_id, [ActivityType.LOCK_OPERATION]
+ )
+
+ if lock_activity is not None:
+ self._available = True
+ self._state = lock_activity.operated_by
+ self._operated_remote = lock_activity.operated_remote
+ self._operated_keypad = lock_activity.operated_keypad
+ self._operated_autorelock = lock_activity.operated_autorelock
+ self._entity_picture = lock_activity.operator_thumbnail_url
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ attributes = {}
+
+ if self._operated_remote is not None:
+ attributes[ATTR_OPERATION_REMOTE] = self._operated_remote
+ if self._operated_keypad is not None:
+ attributes[ATTR_OPERATION_KEYPAD] = self._operated_keypad
+ if self._operated_autorelock is not None:
+ attributes[ATTR_OPERATION_AUTORELOCK] = self._operated_autorelock
+
+ if self._operated_remote:
+ attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_REMOTE
+ elif self._operated_keypad:
+ attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_KEYPAD
+ elif self._operated_autorelock:
+ attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_AUTORELOCK
+ else:
+ attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_MOBILE_DEVICE
+
+ return attributes
+
+ async def async_added_to_hass(self):
+ """Restore ATTR_CHANGED_BY on startup since it is likely no longer in the activity log."""
+ await super().async_added_to_hass()
+
+ last_state = await self.async_get_last_state()
+ if not last_state:
+ return
+
+ self._state = last_state.state
+ if ATTR_ENTITY_PICTURE in last_state.attributes:
+ self._entity_picture = last_state.attributes[ATTR_ENTITY_PICTURE]
+ if ATTR_OPERATION_REMOTE in last_state.attributes:
+ self._operated_remote = last_state.attributes[ATTR_OPERATION_REMOTE]
+ if ATTR_OPERATION_KEYPAD in last_state.attributes:
+ self._operated_keypad = last_state.attributes[ATTR_OPERATION_KEYPAD]
+ if ATTR_OPERATION_AUTORELOCK in last_state.attributes:
+ self._operated_autorelock = last_state.attributes[ATTR_OPERATION_AUTORELOCK]
+
+ @property
+ def entity_picture(self):
+ """Return the entity picture to use in the frontend, if any."""
+ return self._entity_picture
+
+ @property
+ def unique_id(self) -> str:
+ """Get the unique id of the device sensor."""
+ return f"{self._device_id}_lock_operator"
+
+
+class AugustBatterySensor(AugustEntityMixin, Entity):
+ """Representation of an August sensor."""
+
+ def __init__(self, data, sensor_type, device):
+ """Initialize the sensor."""
+ super().__init__(data, device)
+ self._data = data
+ self._sensor_type = sensor_type
+ self._device = device
+ self._state = None
+ self._available = False
+ self._update_from_data()
+
+ @property
+ def available(self):
+ """Return the availability of this sensor."""
+ return self._available
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return UNIT_PERCENTAGE
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return DEVICE_CLASS_BATTERY
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ device_name = self._device.device_name
+ sensor_name = SENSOR_TYPES_BATTERY[self._sensor_type]["name"]
+ return f"{device_name} {sensor_name}"
+
+ @callback
+ def _update_from_data(self):
+ """Get the latest state of the sensor."""
+ state_provider = SENSOR_TYPES_BATTERY[self._sensor_type]["state_provider"]
+ self._state = state_provider(self._detail)
+ self._available = self._state is not None
+
+ @property
+ def unique_id(self) -> str:
+ """Get the unique id of the device sensor."""
+ return f"{self._device_id}_{self._sensor_type}"
diff --git a/homeassistant/components/august/strings.json b/homeassistant/components/august/strings.json
new file mode 100644
index 00000000000..1695d33cd63
--- /dev/null
+++ b/homeassistant/components/august/strings.json
@@ -0,0 +1,32 @@
+{
+ "config" : {
+ "error" : {
+ "unknown" : "Unexpected error",
+ "cannot_connect" : "Failed to connect, please try again",
+ "invalid_auth" : "Invalid authentication"
+ },
+ "abort" : {
+ "already_configured" : "Account is already configured"
+ },
+ "step" : {
+ "validation" : {
+ "title" : "Two factor authentication",
+ "data" : {
+ "code" : "Verification code"
+ },
+ "description" : "Please check your {login_method} ({username}) and enter the verification code below"
+ },
+ "user" : {
+ "description" : "If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'.",
+ "data" : {
+ "timeout" : "Timeout (seconds)",
+ "password" : "Password",
+ "username" : "Username",
+ "login_method" : "Login Method"
+ },
+ "title" : "Setup an August account"
+ }
+ },
+ "title" : "August"
+ }
+}
diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py
new file mode 100644
index 00000000000..81538fa011e
--- /dev/null
+++ b/homeassistant/components/august/subscriber.py
@@ -0,0 +1,45 @@
+"""Base class for August entity."""
+
+
+from homeassistant.core import callback
+from homeassistant.helpers.event import async_track_time_interval
+
+
+class AugustSubscriberMixin:
+ """Base implementation for a subscriber."""
+
+ def __init__(self, hass, update_interval):
+ """Initialize an subscriber."""
+ super().__init__()
+ self._hass = hass
+ self._update_interval = update_interval
+ self._subscriptions = {}
+ self._unsub_interval = None
+
+ @callback
+ def async_subscribe_device_id(self, device_id, update_callback):
+ """Add an callback subscriber."""
+ if not self._subscriptions:
+ self._unsub_interval = async_track_time_interval(
+ self._hass, self._async_refresh, self._update_interval
+ )
+ self._subscriptions.setdefault(device_id, []).append(update_callback)
+
+ @callback
+ def async_unsubscribe_device_id(self, device_id, update_callback):
+ """Remove a callback subscriber."""
+ self._subscriptions[device_id].remove(update_callback)
+ if not self._subscriptions[device_id]:
+ del self._subscriptions[device_id]
+ if not self._subscriptions:
+ self._unsub_interval()
+ self._unsub_interval = None
+
+ @callback
+ def async_signal_device_id_update(self, device_id):
+ """Call the callbacks for a device_id."""
+ if not self._subscriptions.get(device_id):
+ return
+
+ for update_callback in self._subscriptions[device_id]:
+ update_callback()
diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py
index a2645e5d7cb..69a513dd8fb 100644
--- a/homeassistant/components/aurora_abb_powerone/sensor.py
+++ b/homeassistant/components/aurora_abb_powerone/sensor.py
@@ -96,7 +96,6 @@ class AuroraABBSolarPVMonitorSensor(Entity):
if "No response after" in str(error):
_LOGGER.debug("No response from inverter (could be dark)")
else:
- # print("Exception!!: {}".format(str(e)))
raise error
self._state = None
finally:
diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py
index 3266ae65d7a..a2d015c279b 100644
--- a/homeassistant/components/auth/indieauth.py
+++ b/homeassistant/components/auth/indieauth.py
@@ -33,8 +33,8 @@ async def verify_redirect_uri(hass, client_id, redirect_uri):
# Whitelist the iOS and Android callbacks so that people can link apps
# without being connected to the internet.
if redirect_uri == "homeassistant://auth-callback" and client_id in (
- "https://home-assistant.io/android",
- "https://home-assistant.io/iOS",
+ "https://www.home-assistant.io/android",
+ "https://www.home-assistant.io/iOS",
):
return True
diff --git a/homeassistant/components/automatic/device_tracker.py b/homeassistant/components/automatic/device_tracker.py
index 3c9e33cdc84..0fc747ffaa9 100644
--- a/homeassistant/components/automatic/device_tracker.py
+++ b/homeassistant/components/automatic/device_tracker.py
@@ -28,7 +28,6 @@ from homeassistant.helpers.event import async_track_time_interval
_LOGGER = logging.getLogger(__name__)
ATTR_FUEL_LEVEL = "fuel_level"
-AUTOMATIC_CONFIG_FILE = ".automatic/session-{}.json"
CONF_CLIENT_ID = "client_id"
CONF_CURRENT_LOCATION = "current_location"
@@ -95,7 +94,7 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None):
request_kwargs={"timeout": DEFAULT_TIMEOUT},
)
- filename = AUTOMATIC_CONFIG_FILE.format(config[CONF_CLIENT_ID])
+ filename = f".automatic/session-{config[CONF_CLIENT_ID]}.json"
refresh_token = yield from hass.async_add_job(
_get_refresh_token_from_file, hass, filename
)
diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py
index 1a73de885c0..c19a0033f86 100644
--- a/homeassistant/components/automation/__init__.py
+++ b/homeassistant/components/automation/__init__.py
@@ -57,7 +57,6 @@ CONDITION_TYPE_AND = "and"
CONDITION_TYPE_OR = "or"
DEFAULT_CONDITION_TYPE = CONDITION_TYPE_AND
-DEFAULT_HIDE_ENTITY = False
DEFAULT_INITIAL_STATE = True
ATTR_LAST_TRIGGERED = "last_triggered"
@@ -72,9 +71,7 @@ AutomationActionType = Callable[[HomeAssistant, TemplateVarsType], Awaitable[Non
def _platform_validator(config):
"""Validate it is a valid platform."""
try:
- platform = importlib.import_module(
- ".{}".format(config[CONF_PLATFORM]), __name__
- )
+ platform = importlib.import_module(f".{config[CONF_PLATFORM]}", __name__)
except ImportError:
raise vol.Invalid("Invalid platform specified") from None
@@ -94,7 +91,7 @@ _TRIGGER_SCHEMA = vol.All(
_CONDITION_SCHEMA = vol.All(cv.ensure_list, [cv.CONDITION_SCHEMA])
PLATFORM_SCHEMA = vol.All(
- cv.deprecated(CONF_HIDE_ENTITY, invalidation_version="0.107"),
+ cv.deprecated(CONF_HIDE_ENTITY, invalidation_version="0.110"),
vol.Schema(
{
# str on purpose
@@ -102,7 +99,7 @@ PLATFORM_SCHEMA = vol.All(
CONF_ALIAS: cv.string,
vol.Optional(CONF_DESCRIPTION): cv.string,
vol.Optional(CONF_INITIAL_STATE): cv.boolean,
- vol.Optional(CONF_HIDE_ENTITY, default=DEFAULT_HIDE_ENTITY): cv.boolean,
+ vol.Optional(CONF_HIDE_ENTITY): cv.boolean,
vol.Required(CONF_TRIGGER): _TRIGGER_SCHEMA,
vol.Optional(CONF_CONDITION): _CONDITION_SCHEMA,
vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA,
@@ -221,7 +218,7 @@ async def async_setup(hass, config):
await _async_process_config(hass, conf, component)
async_register_admin_service(
- hass, DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({}),
+ hass, DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({})
)
return True
@@ -237,7 +234,6 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
trigger_config,
cond_func,
action_script,
- hidden,
initial_state,
):
"""Initialize an automation entity."""
@@ -248,7 +244,6 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
self._cond_func = cond_func
self.action_script = action_script
self._last_triggered = None
- self._hidden = hidden
self._initial_state = initial_state
self._is_enabled = False
self._referenced_entities: Optional[Set[str]] = None
@@ -274,11 +269,6 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
"""Return the entity state attributes."""
return {ATTR_LAST_TRIGGERED: self._last_triggered}
- @property
- def hidden(self) -> bool:
- """Return True if the automation entity should be hidden from UIs."""
- return self._hidden
-
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
@@ -395,10 +385,8 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
try:
await self.action_script.async_run(variables, trigger_context)
- except Exception as err: # pylint: disable=broad-except
- self.action_script.async_log_exception(
- _LOGGER, f"Error while executing automation {self.entity_id}", err
- )
+ except Exception: # pylint: disable=broad-except
+ pass
self._last_triggered = utcnow()
await self.async_update_ha_state()
@@ -456,9 +444,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
info = {"name": self._name}
for conf in self._trigger_config:
- platform = importlib.import_module(
- ".{}".format(conf[CONF_PLATFORM]), __name__
- )
+ platform = importlib.import_module(f".{conf[CONF_PLATFORM]}", __name__)
remove = await platform.async_attach_trigger(
self.hass, conf, self.async_trigger, info
@@ -505,10 +491,11 @@ async def _async_process_config(hass, config, component):
automation_id = config_block.get(CONF_ID)
name = config_block.get(CONF_ALIAS) or f"{config_key} {list_no}"
- hidden = config_block[CONF_HIDE_ENTITY]
initial_state = config_block.get(CONF_INITIAL_STATE)
- action_script = script.Script(hass, config_block.get(CONF_ACTION, {}), name)
+ action_script = script.Script(
+ hass, config_block.get(CONF_ACTION, {}), name, logger=_LOGGER
+ )
if CONF_CONDITION in config_block:
cond_func = await _async_process_if(hass, config, config_block)
@@ -524,7 +511,6 @@ async def _async_process_config(hass, config, component):
config_block[CONF_TRIGGER],
cond_func,
action_script,
- hidden,
initial_state,
)
diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py
index d11472a2128..d29a561f378 100644
--- a/homeassistant/components/automation/config.py
+++ b/homeassistant/components/automation/config.py
@@ -26,7 +26,7 @@ async def async_validate_config_item(hass, config, full_config=None):
triggers = []
for trigger in config[CONF_TRIGGER]:
trigger_platform = importlib.import_module(
- "..{}".format(trigger[CONF_PLATFORM]), __name__
+ f"..{trigger[CONF_PLATFORM]}", __name__
)
if hasattr(trigger_platform, "async_validate_trigger_config"):
trigger = await trigger_platform.async_validate_trigger_config(
diff --git a/homeassistant/components/automation/geo_location.py b/homeassistant/components/automation/geo_location.py
index 5dc4f3c80f6..92094a751a0 100644
--- a/homeassistant/components/automation/geo_location.py
+++ b/homeassistant/components/automation/geo_location.py
@@ -58,7 +58,6 @@ async def async_attach_trigger(hass, config, action, automation_info):
from_match = condition.zone(hass, zone_state, from_state)
to_match = condition.zone(hass, zone_state, to_state)
- # pylint: disable=too-many-boolean-expressions
if (
trigger_event == EVENT_ENTER
and not from_match
diff --git a/homeassistant/components/automation/manifest.json b/homeassistant/components/automation/manifest.json
index 34261cba5a9..48d8c58dfe1 100644
--- a/homeassistant/components/automation/manifest.json
+++ b/homeassistant/components/automation/manifest.json
@@ -3,7 +3,8 @@
"name": "Automation",
"documentation": "https://www.home-assistant.io/integrations/automation",
"requirements": [],
- "dependencies": ["device_automation", "group", "webhook"],
+ "dependencies": [],
+ "after_dependencies": ["device_automation", "webhook"],
"codeowners": ["@home-assistant/core"],
"quality_scale": "internal"
}
diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py
index e944b66751b..d8f71f5bdf3 100644
--- a/homeassistant/components/automation/numeric_state.py
+++ b/homeassistant/components/automation/numeric_state.py
@@ -19,6 +19,23 @@ from homeassistant.helpers.event import async_track_same_state, async_track_stat
# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs
# mypy: no-check-untyped-defs
+
+def validate_above_below(value):
+ """Validate that above and below can co-exist."""
+ above = value.get(CONF_ABOVE)
+ below = value.get(CONF_BELOW)
+
+ if above is None or below is None:
+ return value
+
+ if above > below:
+ raise vol.Invalid(
+ f"A value can never be above {above} and below {below} at the same time. You probably want two different triggers.",
+ )
+
+ return value
+
+
TRIGGER_SCHEMA = vol.All(
vol.Schema(
{
@@ -35,6 +52,7 @@ TRIGGER_SCHEMA = vol.All(
}
),
cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE),
+ validate_above_below,
)
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/automation/zone.py b/homeassistant/components/automation/zone.py
index 3dba1a4df35..14233d783f9 100644
--- a/homeassistant/components/automation/zone.py
+++ b/homeassistant/components/automation/zone.py
@@ -53,7 +53,6 @@ async def async_attach_trigger(hass, config, action, automation_info):
from_match = False
to_match = condition.zone(hass, zone_state, to_s)
- # pylint: disable=too-many-boolean-expressions
if (
event == EVENT_ENTER
and not from_match
diff --git a/homeassistant/components/avri/__init__.py b/homeassistant/components/avri/__init__.py
new file mode 100644
index 00000000000..4d99b2ed0e4
--- /dev/null
+++ b/homeassistant/components/avri/__init__.py
@@ -0,0 +1 @@
+"""The avri component."""
diff --git a/homeassistant/components/avri/manifest.json b/homeassistant/components/avri/manifest.json
new file mode 100644
index 00000000000..7c9e7bc348c
--- /dev/null
+++ b/homeassistant/components/avri/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "avri",
+ "name": "Avri",
+ "documentation": "https://www.home-assistant.io/integrations/avri",
+ "requirements": ["avri-api==0.1.7"],
+ "dependencies": [],
+ "codeowners": ["@timvancann"]
+}
diff --git a/homeassistant/components/avri/sensor.py b/homeassistant/components/avri/sensor.py
new file mode 100644
index 00000000000..a221147f065
--- /dev/null
+++ b/homeassistant/components/avri/sensor.py
@@ -0,0 +1,116 @@
+"""Support for Avri waste curbside collection pickup."""
+from datetime import timedelta
+import logging
+
+from avri.api import Avri, AvriException
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+CONF_COUNTRY_CODE = "country_code"
+CONF_ZIP_CODE = "zip_code"
+CONF_HOUSE_NUMBER = "house_number"
+CONF_HOUSE_NUMBER_EXTENSION = "house_number_extension"
+DEFAULT_NAME = "avri"
+ICON = "mdi:trash-can-outline"
+SCAN_INTERVAL = timedelta(hours=4)
+DEFAULT_COUNTRY_CODE = "NL"
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
+ {
+ vol.Required(CONF_ZIP_CODE): cv.string,
+ vol.Required(CONF_HOUSE_NUMBER): cv.positive_int,
+ vol.Optional(CONF_HOUSE_NUMBER_EXTENSION): cv.string,
+ vol.Optional(CONF_COUNTRY_CODE, default=DEFAULT_COUNTRY_CODE): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ }
+)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Avri Waste platform."""
+ client = Avri(
+ postal_code=config[CONF_ZIP_CODE],
+ house_nr=config[CONF_HOUSE_NUMBER],
+ house_nr_extension=config.get(CONF_HOUSE_NUMBER_EXTENSION),
+ country_code=config[CONF_COUNTRY_CODE],
+ )
+
+ try:
+ each_upcoming = client.upcoming_of_each()
+ except AvriException as ex:
+ raise PlatformNotReady from ex
+ else:
+ entities = [
+ AvriWasteUpcoming(config[CONF_NAME], client, upcoming.name)
+ for upcoming in each_upcoming
+ ]
+ add_entities(entities, True)
+
+
+class AvriWasteUpcoming(Entity):
+ """Avri Waste Sensor."""
+
+ def __init__(self, name: str, client: Avri, waste_type: str):
+ """Initialize the sensor."""
+ self._waste_type = waste_type
+ self._name = f"{name}_{self._waste_type}"
+ self._state = None
+ self._client = client
+ self._state_available = False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return (
+ f"{self._waste_type}"
+ f"-{self._client.country_code}"
+ f"-{self._client.postal_code}"
+ f"-{self._client.house_nr}"
+ f"-{self._client.house_nr_extension}"
+ )
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._state_available
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return ICON
+
+ def update(self):
+ """Update device state."""
+ try:
+ pickup_events = self._client.upcoming_of_each()
+ except AvriException as ex:
+ _LOGGER.error(
+ "There was an error retrieving upcoming garbage pickups: %s", ex
+ )
+ self._state_available = False
+ self._state = None
+ else:
+ self._state_available = True
+ matched_events = list(
+ filter(lambda event: event.name == self._waste_type, pickup_events)
+ )
+ if not matched_events:
+ self._state = None
+ else:
+ self._state = matched_events[0].day.date()
diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py
index f15e4a80e36..301055c7e61 100644
--- a/homeassistant/components/awair/sensor.py
+++ b/homeassistant/components/awair/sensor.py
@@ -8,11 +8,15 @@ from python_awair import AwairClient
import voluptuous as vol
from homeassistant.const import (
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_PARTS_PER_MILLION,
CONF_ACCESS_TOKEN,
CONF_DEVICES,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -45,39 +49,39 @@ SENSOR_TYPES = {
},
"HUMID": {
"device_class": DEVICE_CLASS_HUMIDITY,
- "unit_of_measurement": "%",
+ "unit_of_measurement": UNIT_PERCENTAGE,
"icon": "mdi:water-percent",
},
"CO2": {
"device_class": DEVICE_CLASS_CARBON_DIOXIDE,
- "unit_of_measurement": "ppm",
+ "unit_of_measurement": CONCENTRATION_PARTS_PER_MILLION,
"icon": "mdi:periodic-table-co2",
},
"VOC": {
"device_class": DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
- "unit_of_measurement": "ppb",
+ "unit_of_measurement": CONCENTRATION_PARTS_PER_BILLION,
"icon": "mdi:cloud",
},
# Awair docs don't actually specify the size they measure for 'dust',
# but 2.5 allows the sensor to show up in HomeKit
"DUST": {
"device_class": DEVICE_CLASS_PM2_5,
- "unit_of_measurement": "µg/m3",
+ "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
"icon": "mdi:cloud",
},
"PM25": {
"device_class": DEVICE_CLASS_PM2_5,
- "unit_of_measurement": "µg/m3",
+ "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
"icon": "mdi:cloud",
},
"PM10": {
"device_class": DEVICE_CLASS_PM10,
- "unit_of_measurement": "µg/m3",
+ "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
"icon": "mdi:cloud",
},
"score": {
"device_class": DEVICE_CLASS_SCORE,
- "unit_of_measurement": "%",
+ "unit_of_measurement": UNIT_PERCENTAGE,
"icon": "mdi:percent",
},
}
diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py
index b3593179ffc..d7551abebc1 100644
--- a/homeassistant/components/axis/binary_sensor.py
+++ b/homeassistant/components/axis/binary_sensor.py
@@ -79,8 +79,8 @@ class AxisBinarySensor(AxisEventBase, BinarySensorDevice):
and self.event.id
and self.device.api.vapix.ports[self.event.id].name
):
- return "{} {}".format(
- self.device.name, self.device.api.vapix.ports[self.event.id].name
+ return (
+ f"{self.device.name} {self.device.api.vapix.ports[self.event.id].name}"
)
return super().name
diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py
index 51d6b6805cc..3cf84ce2288 100644
--- a/homeassistant/components/axis/camera.py
+++ b/homeassistant/components/axis/camera.py
@@ -21,10 +21,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .axis_base import AxisEntityBase
from .const import DOMAIN as AXIS_DOMAIN
-AXIS_IMAGE = "http://{}:{}/axis-cgi/jpg/image.cgi"
-AXIS_VIDEO = "http://{}:{}/axis-cgi/mjpg/video.cgi"
-AXIS_STREAM = "rtsp://{}:{}@{}/axis-media/media.amp?videocodec=h264"
-
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Axis camera video stream."""
@@ -36,11 +32,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
CONF_NAME: config_entry.data[CONF_NAME],
CONF_USERNAME: config_entry.data[CONF_USERNAME],
CONF_PASSWORD: config_entry.data[CONF_PASSWORD],
- CONF_MJPEG_URL: AXIS_VIDEO.format(
- config_entry.data[CONF_HOST], config_entry.data[CONF_PORT],
+ CONF_MJPEG_URL: (
+ f"http://{config_entry.data[CONF_HOST]}"
+ f":{config_entry.data[CONF_PORT]}/axis-cgi/mjpg/video.cgi"
),
- CONF_STILL_IMAGE_URL: AXIS_IMAGE.format(
- config_entry.data[CONF_HOST], config_entry.data[CONF_PORT],
+ CONF_STILL_IMAGE_URL: (
+ f"http://{config_entry.data[CONF_HOST]}"
+ f":{config_entry.data[CONF_PORT]}/axis-cgi/jpg/image.cgi"
),
CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION,
}
@@ -72,17 +70,19 @@ class AxisCamera(AxisEntityBase, MjpegCamera):
async def stream_source(self):
"""Return the stream source."""
- return AXIS_STREAM.format(
- self.device.config_entry.data[CONF_USERNAME],
- self.device.config_entry.data[CONF_PASSWORD],
- self.device.host,
+ return (
+ f"rtsp://{self.device.config_entry.data[CONF_USERNAME]}´"
+ f":{self.device.config_entry.data[CONF_PASSWORD]}"
+ f"@{self.device.host}/axis-media/media.amp?videocodec=h264"
)
def _new_address(self):
"""Set new device address for video stream."""
port = self.device.config_entry.data[CONF_PORT]
- self._mjpeg_url = AXIS_VIDEO.format(self.device.host, port)
- self._still_image_url = AXIS_IMAGE.format(self.device.host, port)
+ self._mjpeg_url = (f"http://{self.device.host}:{port}/axis-cgi/mjpg/video.cgi",)
+ self._still_image_url = (
+ f"http://{self.device.host}:{port}/axis-cgi/jpg/image.cgi"
+ )
@property
def unique_id(self):
diff --git a/homeassistant/components/axis/switch.py b/homeassistant/components/axis/switch.py
index a83460bc529..ed822543a00 100644
--- a/homeassistant/components/axis/switch.py
+++ b/homeassistant/components/axis/switch.py
@@ -53,8 +53,8 @@ class AxisSwitch(AxisEventBase, SwitchDevice):
def name(self):
"""Return the name of the event."""
if self.event.id and self.device.api.vapix.ports[self.event.id].name:
- return "{} {}".format(
- self.device.name, self.device.api.vapix.ports[self.event.id].name
+ return (
+ f"{self.device.name} {self.device.api.vapix.ports[self.event.id].name}"
)
return super().name
diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py
index 7e141cd8060..cc59790b646 100644
--- a/homeassistant/components/azure_event_hub/__init__.py
+++ b/homeassistant/components/azure_event_hub/__init__.py
@@ -47,8 +47,9 @@ async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]):
"""Activate Azure EH component."""
config = yaml_config[DOMAIN]
- event_hub_address = "amqps://{}.servicebus.windows.net/{}".format(
- config[CONF_EVENT_HUB_NAMESPACE], config[CONF_EVENT_HUB_INSTANCE_NAME]
+ event_hub_address = (
+ f"amqps://{config[CONF_EVENT_HUB_NAMESPACE]}"
+ f".servicebus.windows.net/{config[CONF_EVENT_HUB_INSTANCE_NAME]}"
)
entities_filter = config[CONF_FILTER]
diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py
index 1d3720f6723..c2e9e220a20 100644
--- a/homeassistant/components/bayesian/binary_sensor.py
+++ b/homeassistant/components/bayesian/binary_sensor.py
@@ -1,5 +1,6 @@
"""Use Bayesian Inference to trigger a binary sensor."""
from collections import OrderedDict
+from itertools import chain
import voluptuous as vol
@@ -21,6 +22,7 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import async_track_state_change
ATTR_OBSERVATIONS = "observations"
+ATTR_OCCURRED_OBSERVATION_ENTITIES = "occurred_observation_entities"
ATTR_PROBABILITY = "probability"
ATTR_PROBABILITY_THRESHOLD = "probability_threshold"
@@ -126,6 +128,15 @@ class BayesianBinarySensor(BinarySensorDevice):
self.probability = prior
self.current_obs = OrderedDict({})
+ self.entity_obs_dict = []
+
+ for obs in self._observations:
+ if "entity_id" in obs:
+ self.entity_obs_dict.append([obs.get("entity_id")])
+ if "value_template" in obs:
+ self.entity_obs_dict.append(
+ list(obs.get(CONF_VALUE_TEMPLATE).extract_entities())
+ )
to_observe = set()
for obs in self._observations:
@@ -251,6 +262,13 @@ class BayesianBinarySensor(BinarySensorDevice):
"""Return the state attributes of the sensor."""
return {
ATTR_OBSERVATIONS: list(self.current_obs.values()),
+ ATTR_OCCURRED_OBSERVATION_ENTITIES: list(
+ set(
+ chain.from_iterable(
+ self.entity_obs_dict[obs] for obs in self.current_obs.keys()
+ )
+ )
+ ),
ATTR_PROBABILITY: round(self.probability, 2),
ATTR_PROBABILITY_THRESHOLD: self._probability_threshold,
}
diff --git a/homeassistant/components/beewi_smartclim/sensor.py b/homeassistant/components/beewi_smartclim/sensor.py
index 187ca411988..ec124b24971 100644
--- a/homeassistant/components/beewi_smartclim/sensor.py
+++ b/homeassistant/components/beewi_smartclim/sensor.py
@@ -12,6 +12,7 @@ from homeassistant.const import (
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -24,8 +25,8 @@ DEFAULT_NAME = "BeeWi SmartClim"
# Sensor config
SENSOR_TYPES = [
[DEVICE_CLASS_TEMPERATURE, "Temperature", TEMP_CELSIUS],
- [DEVICE_CLASS_HUMIDITY, "Humidity", "%"],
- [DEVICE_CLASS_BATTERY, "Battery", "%"],
+ [DEVICE_CLASS_HUMIDITY, "Humidity", UNIT_PERCENTAGE],
+ [DEVICE_CLASS_BATTERY, "Battery", UNIT_PERCENTAGE],
]
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
diff --git a/homeassistant/components/binary_sensor/.translations/zh-Hans.json b/homeassistant/components/binary_sensor/.translations/zh-Hans.json
new file mode 100644
index 00000000000..aeb24e5056a
--- /dev/null
+++ b/homeassistant/components/binary_sensor/.translations/zh-Hans.json
@@ -0,0 +1,55 @@
+{
+ "device_automation": {
+ "condition_type": {
+ "is_bat_low": "{entity_name} \u7535\u6c60\u7535\u91cf\u4f4e",
+ "is_cold": "{entity_name} \u8fc7\u51b7",
+ "is_connected": "{entity_name} \u5df2\u8fde\u63a5",
+ "is_gas": "{entity_name} \u68c0\u6d4b\u5230\u71c3\u6c14\u6cc4\u6f0f",
+ "is_hot": "{entity_name} \u8fc7\u70ed",
+ "is_light": "{entity_name} \u68c0\u6d4b\u5230\u5149\u7ebf",
+ "is_locked": "{entity_name} \u5df2\u9501\u5b9a",
+ "is_moist": "{entity_name} \u6f6e\u6e7f",
+ "is_motion": "{entity_name} \u68c0\u6d4b\u5230\u6709\u4eba",
+ "is_moving": "{entity_name} \u6b63\u5728\u79fb\u52a8",
+ "is_no_gas": "{entity_name} \u672a\u68c0\u6d4b\u5230\u71c3\u6c14\u6cc4\u6f0f",
+ "is_no_light": "{entity_name} \u672a\u68c0\u6d4b\u5230\u5149\u7ebf",
+ "is_no_motion": "{entity_name} \u672a\u68c0\u6d4b\u5230\u6709\u4eba",
+ "is_no_problem": "{entity_name} \u672a\u53d1\u73b0\u95ee\u9898",
+ "is_no_smoke": "{entity_name} \u672a\u68c0\u6d4b\u5230\u70df\u96fe",
+ "is_no_sound": "{entity_name} \u672a\u68c0\u6d4b\u5230\u58f0\u97f3",
+ "is_no_vibration": "{entity_name} \u672a\u68c0\u6d4b\u5230\u632f\u52a8",
+ "is_not_bat_low": "{entity_name} \u7535\u6c60\u7535\u91cf\u6b63\u5e38",
+ "is_not_cold": "{entity_name} \u4e0d\u51b7",
+ "is_not_connected": "{entity_name} \u5df2\u65ad\u5f00",
+ "is_not_hot": "{entity_name} \u4e0d\u70ed",
+ "is_not_locked": "{entity_name} \u5df2\u89e3\u9501",
+ "is_not_moist": "{entity_name} \u5e72\u71e5",
+ "is_not_moving": "{entity_name} \u9759\u6b62",
+ "is_not_open": "{entity_name} \u5df2\u5173\u95ed",
+ "is_not_plugged_in": "{entity_name} \u672a\u63d2\u5165",
+ "is_not_powered": "{entity_name} \u672a\u901a\u7535",
+ "is_not_present": "{entity_name} \u4e0d\u5728\u5bb6",
+ "is_not_unsafe": "{entity_name} \u5b89\u5168",
+ "is_off": "{entity_name} \u5df2\u5173\u95ed",
+ "is_on": "{entity_name} \u5df2\u5f00\u542f",
+ "is_open": "{entity_name} \u5df2\u6253\u5f00",
+ "is_plugged_in": "{entity_name} \u5df2\u63d2\u5165",
+ "is_powered": "{entity_name} \u5df2\u901a\u7535",
+ "is_present": "{entity_name} \u5728\u5bb6",
+ "is_problem": "{entity_name} \u53d1\u73b0\u95ee\u9898",
+ "is_smoke": "{entity_name} \u68c0\u6d4b\u5230\u70df\u96fe",
+ "is_sound": "{entity_name} \u68c0\u6d4b\u5230\u58f0\u97f3",
+ "is_unsafe": "{entity_name} \u4e0d\u5b89\u5168",
+ "is_vibration": "{entity_name} \u68c0\u6d4b\u5230\u632f\u52a8"
+ },
+ "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",
+ "hot": "{entity_name} \u53d8\u70ed",
+ "light": "{entity_name} \u5f00\u59cb\u68c0\u6d4b\u5230\u5149\u7ebf"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py
index 6a8651be6dc..a488fa1e2fa 100644
--- a/homeassistant/components/bitcoin/sensor.py
+++ b/homeassistant/components/bitcoin/sensor.py
@@ -6,7 +6,13 @@ from blockchain import exchangerates, statistics
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import ATTR_ATTRIBUTION, CONF_CURRENCY, CONF_DISPLAY_OPTIONS
+from homeassistant.const import (
+ ATTR_ATTRIBUTION,
+ CONF_CURRENCY,
+ CONF_DISPLAY_OPTIONS,
+ TIME_MINUTES,
+ TIME_SECONDS,
+)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -27,9 +33,9 @@ OPTION_TYPES = {
"btc_mined": ["Mined", "BTC"],
"trade_volume_usd": ["Trade volume", "USD"],
"difficulty": ["Difficulty", None],
- "minutes_between_blocks": ["Time between Blocks", "min"],
+ "minutes_between_blocks": ["Time between Blocks", TIME_MINUTES],
"number_of_transactions": ["No. of Transactions", None],
- "hash_rate": ["Hash rate", "PH/s"],
+ "hash_rate": ["Hash rate", f"PH/{TIME_SECONDS}"],
"timestamp": ["Timestamp", None],
"mined_blocks": ["Mined Blocks", None],
"blocks_size": ["Block size", None],
@@ -118,45 +124,45 @@ class BitcoinSensor(Entity):
self._state = ticker[self._currency].p15min
self._unit_of_measurement = self._currency
elif self.type == "trade_volume_btc":
- self._state = "{0:.1f}".format(stats.trade_volume_btc)
+ self._state = f"{stats.trade_volume_btc:.1f}"
elif self.type == "miners_revenue_usd":
- self._state = "{0:.0f}".format(stats.miners_revenue_usd)
+ self._state = f"{stats.miners_revenue_usd:.0f}"
elif self.type == "btc_mined":
- self._state = "{}".format(stats.btc_mined * 0.00000001)
+ self._state = str(stats.btc_mined * 0.00000001)
elif self.type == "trade_volume_usd":
- self._state = "{0:.1f}".format(stats.trade_volume_usd)
+ self._state = f"{stats.trade_volume_usd:.1f}"
elif self.type == "difficulty":
- self._state = "{0:.0f}".format(stats.difficulty)
+ self._state = f"{stats.difficulty:.0f}"
elif self.type == "minutes_between_blocks":
- self._state = "{0:.2f}".format(stats.minutes_between_blocks)
+ self._state = f"{stats.minutes_between_blocks:.2f}"
elif self.type == "number_of_transactions":
- self._state = "{}".format(stats.number_of_transactions)
+ self._state = str(stats.number_of_transactions)
elif self.type == "hash_rate":
- self._state = "{0:.1f}".format(stats.hash_rate * 0.000001)
+ self._state = f"{stats.hash_rate * 0.000001:.1f}"
elif self.type == "timestamp":
self._state = stats.timestamp
elif self.type == "mined_blocks":
- self._state = "{}".format(stats.mined_blocks)
+ self._state = str(stats.mined_blocks)
elif self.type == "blocks_size":
- self._state = "{0:.1f}".format(stats.blocks_size)
+ self._state = f"{stats.blocks_size:.1f}"
elif self.type == "total_fees_btc":
- self._state = "{0:.2f}".format(stats.total_fees_btc * 0.00000001)
+ self._state = f"{stats.total_fees_btc * 0.00000001:.2f}"
elif self.type == "total_btc_sent":
- self._state = "{0:.2f}".format(stats.total_btc_sent * 0.00000001)
+ self._state = f"{stats.total_btc_sent * 0.00000001:.2f}"
elif self.type == "estimated_btc_sent":
- self._state = "{0:.2f}".format(stats.estimated_btc_sent * 0.00000001)
+ self._state = f"{stats.estimated_btc_sent * 0.00000001:.2f}"
elif self.type == "total_btc":
- self._state = "{0:.2f}".format(stats.total_btc * 0.00000001)
+ self._state = f"{stats.total_btc * 0.00000001:.2f}"
elif self.type == "total_blocks":
- self._state = "{0:.0f}".format(stats.total_blocks)
+ self._state = f"{stats.total_blocks:.0f}"
elif self.type == "next_retarget":
- self._state = "{0:.2f}".format(stats.next_retarget)
+ self._state = f"{stats.next_retarget:.2f}"
elif self.type == "estimated_transaction_volume_usd":
- self._state = "{0:.2f}".format(stats.estimated_transaction_volume_usd)
+ self._state = f"{stats.estimated_transaction_volume_usd:.2f}"
elif self.type == "miners_revenue_btc":
- self._state = "{0:.1f}".format(stats.miners_revenue_btc * 0.00000001)
+ self._state = f"{stats.miners_revenue_btc * 0.00000001:.1f}"
elif self.type == "market_price_usd":
- self._state = "{0:.2f}".format(stats.market_price_usd)
+ self._state = f"{stats.market_price_usd:.2f}"
class BitcoinData:
diff --git a/homeassistant/components/bizkaibus/sensor.py b/homeassistant/components/bizkaibus/sensor.py
index 931fbbb834d..c58873473d5 100644
--- a/homeassistant/components/bizkaibus/sensor.py
+++ b/homeassistant/components/bizkaibus/sensor.py
@@ -6,7 +6,7 @@ from bizkaibus.bizkaibus import BizkaibusData
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_NAME
+from homeassistant.const import CONF_NAME, TIME_MINUTES
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -62,7 +62,7 @@ class BizkaibusSensor(Entity):
@property
def unit_of_measurement(self):
"""Return the unit of measurement of the sensor."""
- return "minutes"
+ return TIME_MINUTES
def update(self):
"""Get the latest data from the webservice."""
diff --git a/homeassistant/components/bloomsky/binary_sensor.py b/homeassistant/components/bloomsky/binary_sensor.py
index cc6562a0bc1..516fa42cb5c 100644
--- a/homeassistant/components/bloomsky/binary_sensor.py
+++ b/homeassistant/components/bloomsky/binary_sensor.py
@@ -40,7 +40,7 @@ class BloomSkySensor(BinarySensorDevice):
self._bloomsky = bs
self._device_id = device["DeviceID"]
self._sensor_name = sensor_name
- self._name = "{} {}".format(device["DeviceName"], sensor_name)
+ self._name = f"{device['DeviceName']} {sensor_name}"
self._state = None
self._unique_id = f"{self._device_id}-{self._sensor_name}"
diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py
index 84871b7b30e..b07bca948bd 100644
--- a/homeassistant/components/bloomsky/sensor.py
+++ b/homeassistant/components/bloomsky/sensor.py
@@ -4,7 +4,12 @@ import logging
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, TEMP_FAHRENHEIT
+from homeassistant.const import (
+ CONF_MONITORED_CONDITIONS,
+ TEMP_CELSIUS,
+ TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
+)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -25,7 +30,7 @@ SENSOR_TYPES = [
# Sensor units - these do not currently align with the API documentation
SENSOR_UNITS_IMPERIAL = {
"Temperature": TEMP_FAHRENHEIT,
- "Humidity": "%",
+ "Humidity": UNIT_PERCENTAGE,
"Pressure": "inHg",
"Luminance": "cd/m²",
"Voltage": "mV",
@@ -34,7 +39,7 @@ SENSOR_UNITS_IMPERIAL = {
# Metric units
SENSOR_UNITS_METRIC = {
"Temperature": TEMP_CELSIUS,
- "Humidity": "%",
+ "Humidity": UNIT_PERCENTAGE,
"Pressure": "mbar",
"Luminance": "cd/m²",
"Voltage": "mV",
@@ -70,7 +75,7 @@ class BloomSkySensor(Entity):
self._bloomsky = bs
self._device_id = device["DeviceID"]
self._sensor_name = sensor_name
- self._name = "{} {}".format(device["DeviceName"], sensor_name)
+ self._name = f"{device['DeviceName']} {sensor_name}"
self._state = None
self._unique_id = f"{self._device_id}-{self._sensor_name}"
diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py
index db5c65eab8b..3ca9cb1f623 100644
--- a/homeassistant/components/bluesound/media_player.py
+++ b/homeassistant/components/bluesound/media_player.py
@@ -500,7 +500,7 @@ class BluesoundPlayer(MediaPlayerDevice):
"image": item.get("@image", ""),
"is_raw_url": True,
"url2": item.get("@url", ""),
- "url": "Preset?id={}".format(item.get("@id", "")),
+ "url": f"Preset?id={item.get('@id', '')}",
}
)
@@ -934,9 +934,7 @@ class BluesoundPlayer(MediaPlayerDevice):
return
selected_source = items[0]
- url = "Play?url={}&preset_id&image={}".format(
- selected_source["url"], selected_source["image"]
- )
+ url = f"Play?url={selected_source['url']}&preset_id&image={selected_source['image']}"
if "is_raw_url" in selected_source and selected_source["is_raw_url"]:
url = selected_source["url"]
@@ -1002,7 +1000,7 @@ class BluesoundPlayer(MediaPlayerDevice):
if self.is_grouped and not self.is_master:
return
- return await self.send_bluesound_command("Play?seek={}".format(float(position)))
+ return await self.send_bluesound_command(f"Play?seek={float(position)}")
async def async_play_media(self, media_type, media_id, **kwargs):
"""
diff --git a/homeassistant/components/bme280/sensor.py b/homeassistant/components/bme280/sensor.py
index e1e33210c9b..28e22c10f20 100644
--- a/homeassistant/components/bme280/sensor.py
+++ b/homeassistant/components/bme280/sensor.py
@@ -8,7 +8,12 @@ import smbus # pylint: disable=import-error
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_FAHRENHEIT
+from homeassistant.const import (
+ CONF_MONITORED_CONDITIONS,
+ CONF_NAME,
+ TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
+)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle
@@ -44,7 +49,7 @@ SENSOR_HUMID = "humidity"
SENSOR_PRESS = "pressure"
SENSOR_TYPES = {
SENSOR_TEMP: ["Temperature", None],
- SENSOR_HUMID: ["Humidity", "%"],
+ SENSOR_HUMID: ["Humidity", UNIT_PERCENTAGE],
SENSOR_PRESS: ["Pressure", "mb"],
}
DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS]
diff --git a/homeassistant/components/bme680/sensor.py b/homeassistant/components/bme680/sensor.py
index 43430f724bb..19d6fbd3086 100644
--- a/homeassistant/components/bme680/sensor.py
+++ b/homeassistant/components/bme680/sensor.py
@@ -8,7 +8,12 @@ from smbus import SMBus # pylint: disable=import-error
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_FAHRENHEIT
+from homeassistant.const import (
+ CONF_MONITORED_CONDITIONS,
+ CONF_NAME,
+ TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
+)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.util.temperature import celsius_to_fahrenheit
@@ -50,10 +55,10 @@ SENSOR_GAS = "gas"
SENSOR_AQ = "airquality"
SENSOR_TYPES = {
SENSOR_TEMP: ["Temperature", None],
- SENSOR_HUMID: ["Humidity", "%"],
+ SENSOR_HUMID: ["Humidity", UNIT_PERCENTAGE],
SENSOR_PRESS: ["Pressure", "mb"],
SENSOR_GAS: ["Gas Resistance", "Ohms"],
- SENSOR_AQ: ["Air Quality", "%"],
+ SENSOR_AQ: ["Air Quality", UNIT_PERCENTAGE],
}
DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS, SENSOR_AQ]
OVERSAMPLING_VALUES = set([0, 1, 2, 4, 8, 16])
diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py
index 6e7723b16ec..273bac8ef0e 100644
--- a/homeassistant/components/bmw_connected_drive/__init__.py
+++ b/homeassistant/components/bmw_connected_drive/__init__.py
@@ -120,7 +120,6 @@ class BMWConnectedDriveAccount:
self, username: str, password: str, region_str: str, name: str, read_only
) -> None:
"""Initialize account."""
-
region = get_region_from_name(region_str)
self.read_only = read_only
diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py
index 591cdadda35..fc3069f284c 100644
--- a/homeassistant/components/bmw_connected_drive/binary_sensor.py
+++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py
@@ -4,9 +4,10 @@ import logging
from bimmer_connected.state import ChargingState, LockState
from homeassistant.components.binary_sensor import BinarySensorDevice
-from homeassistant.const import LENGTH_KILOMETERS
+from homeassistant.const import ATTR_ATTRIBUTION, LENGTH_KILOMETERS
from . import DOMAIN as BMW_DOMAIN
+from .const import ATTRIBUTION
_LOGGER = logging.getLogger(__name__)
@@ -107,7 +108,10 @@ class BMWConnectedDriveSensor(BinarySensorDevice):
def device_state_attributes(self):
"""Return the state attributes of the binary sensor."""
vehicle_state = self._vehicle.state
- result = {"car": self._vehicle.name}
+ result = {
+ "car": self._vehicle.name,
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ }
if self._attribute == "lids":
for lid in vehicle_state.lids:
@@ -143,7 +147,6 @@ class BMWConnectedDriveSensor(BinarySensorDevice):
def update(self):
"""Read new state data from the library."""
-
vehicle_state = self._vehicle.state
# device class opening: On means open, Off means closed
@@ -152,7 +155,7 @@ class BMWConnectedDriveSensor(BinarySensorDevice):
self._state = not vehicle_state.all_lids_closed
if self._attribute == "windows":
self._state = not vehicle_state.all_windows_closed
- # device class safety: On means unsafe, Off means safe
+ # device class lock: On means unlocked, Off means locked
if self._attribute == "door_lock_state":
# Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED
self._state = vehicle_state.door_lock_state not in [
diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py
new file mode 100644
index 00000000000..d1a44b5e5c9
--- /dev/null
+++ b/homeassistant/components/bmw_connected_drive/const.py
@@ -0,0 +1,2 @@
+"""Const file for the BMW Connected Drive integration."""
+ATTRIBUTION = "Data provided by BMW Connected Drive"
diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py
index 5323e94c1c3..7d4ad420af4 100644
--- a/homeassistant/components/bmw_connected_drive/lock.py
+++ b/homeassistant/components/bmw_connected_drive/lock.py
@@ -4,10 +4,12 @@ import logging
from bimmer_connected.state import LockState
from homeassistant.components.lock import LockDevice
-from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED
+from homeassistant.const import ATTR_ATTRIBUTION, STATE_LOCKED, STATE_UNLOCKED
from . import DOMAIN as BMW_DOMAIN
+from .const import ATTRIBUTION
+DOOR_LOCK_STATE = "door_lock_state"
_LOGGER = logging.getLogger(__name__)
@@ -36,6 +38,9 @@ class BMWLock(LockDevice):
self._unique_id = f"{self._vehicle.vin}-{self._attribute}"
self._sensor_name = sensor_name
self._state = None
+ self.door_lock_state_available = (
+ DOOR_LOCK_STATE in self._vehicle.available_attributes
+ )
@property
def should_poll(self):
@@ -59,10 +64,14 @@ class BMWLock(LockDevice):
def device_state_attributes(self):
"""Return the state attributes of the lock."""
vehicle_state = self._vehicle.state
- return {
+ result = {
"car": self._vehicle.name,
- "door_lock_state": vehicle_state.door_lock_state.value,
+ ATTR_ATTRIBUTION: ATTRIBUTION,
}
+ if self.door_lock_state_available:
+ result["door_lock_state"] = vehicle_state.door_lock_state.value
+ result["last_update_reason"] = vehicle_state.last_update_reason
+ return result
@property
def is_locked(self):
@@ -89,7 +98,6 @@ class BMWLock(LockDevice):
def update(self):
"""Update state of the lock."""
-
_LOGGER.debug("%s: updating data for %s", self._vehicle.name, self._attribute)
vehicle_state = self._vehicle.state
diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py
index 3c40900bed8..d7eec8b9479 100644
--- a/homeassistant/components/bmw_connected_drive/sensor.py
+++ b/homeassistant/components/bmw_connected_drive/sensor.py
@@ -4,9 +4,12 @@ import logging
from bimmer_connected.state import ChargingState
from homeassistant.const import (
+ ATTR_ATTRIBUTION,
CONF_UNIT_SYSTEM_IMPERIAL,
LENGTH_KILOMETERS,
LENGTH_MILES,
+ TIME_HOURS,
+ UNIT_PERCENTAGE,
VOLUME_GALLONS,
VOLUME_LITERS,
)
@@ -14,6 +17,7 @@ from homeassistant.helpers.entity import Entity
from homeassistant.helpers.icon import icon_for_battery_level
from . import DOMAIN as BMW_DOMAIN
+from .const import ATTRIBUTION
_LOGGER = logging.getLogger(__name__)
@@ -24,10 +28,10 @@ ATTR_TO_HA_METRIC = {
"remaining_range_fuel": ["mdi:map-marker-distance", LENGTH_KILOMETERS],
"max_range_electric": ["mdi:map-marker-distance", LENGTH_KILOMETERS],
"remaining_fuel": ["mdi:gas-station", VOLUME_LITERS],
- "charging_time_remaining": ["mdi:update", "h"],
+ "charging_time_remaining": ["mdi:update", TIME_HOURS],
"charging_status": ["mdi:battery-charging", None],
# No icon as this is dealt with directly as a special case in icon()
- "charging_level_hv": [None, "%"],
+ "charging_level_hv": [None, UNIT_PERCENTAGE],
}
ATTR_TO_HA_IMPERIAL = {
@@ -37,10 +41,10 @@ ATTR_TO_HA_IMPERIAL = {
"remaining_range_fuel": ["mdi:map-marker-distance", LENGTH_MILES],
"max_range_electric": ["mdi:map-marker-distance", LENGTH_MILES],
"remaining_fuel": ["mdi:gas-station", VOLUME_GALLONS],
- "charging_time_remaining": ["mdi:update", "h"],
+ "charging_time_remaining": ["mdi:update", TIME_HOURS],
"charging_status": ["mdi:battery-charging", None],
# No icon as this is dealt with directly as a special case in icon()
- "charging_level_hv": [None, "%"],
+ "charging_level_hv": [None, UNIT_PERCENTAGE],
}
@@ -99,7 +103,6 @@ class BMWConnectedDriveSensor(Entity):
@property
def icon(self):
"""Icon to use in the frontend, if any."""
-
vehicle_state = self._vehicle.state
charging_state = vehicle_state.charging_status in [ChargingState.CHARGING]
@@ -128,7 +131,10 @@ class BMWConnectedDriveSensor(Entity):
@property
def device_state_attributes(self):
"""Return the state attributes of the sensor."""
- return {"car": self._vehicle.name}
+ return {
+ "car": self._vehicle.name,
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ }
def update(self) -> None:
"""Read new state data from the library."""
diff --git a/homeassistant/components/bom/camera.py b/homeassistant/components/bom/camera.py
index 7460b84f734..3bbd9e39164 100644
--- a/homeassistant/components/bom/camera.py
+++ b/homeassistant/components/bom/camera.py
@@ -75,16 +75,12 @@ def _validate_schema(config):
if config.get(CONF_LOCATION) is None:
if not all(config.get(x) for x in (CONF_ID, CONF_DELTA, CONF_FRAMES)):
raise vol.Invalid(
- "Specify '{}', '{}' and '{}' when '{}' is unspecified".format(
- CONF_ID, CONF_DELTA, CONF_FRAMES, CONF_LOCATION
- )
+ f"Specify '{CONF_ID}', '{CONF_DELTA}' and '{CONF_FRAMES}' when '{CONF_LOCATION}' is unspecified"
)
return config
-LOCATIONS_MSG = "Set '{}' to one of: {}".format(
- CONF_LOCATION, ", ".join(sorted(LOCATIONS))
-)
+LOCATIONS_MSG = f"Set '{CONF_LOCATION}' to one of: {', '.join(sorted(LOCATIONS))}"
XOR_MSG = f"Specify exactly one of '{CONF_ID}' or '{CONF_LOCATION}'"
PLATFORM_SCHEMA = vol.All(
@@ -106,7 +102,7 @@ PLATFORM_SCHEMA = vol.All(
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up BOM radar-loop camera component."""
- location = config.get(CONF_LOCATION) or "ID {}".format(config.get(CONF_ID))
+ location = config.get(CONF_LOCATION) or f"ID {config.get(CONF_ID)}"
name = config.get(CONF_NAME) or f"BOM Radar Loop - {location}"
args = [
config.get(x)
diff --git a/homeassistant/components/bom/manifest.json b/homeassistant/components/bom/manifest.json
index 211d1dafc7b..13537c53d0c 100644
--- a/homeassistant/components/bom/manifest.json
+++ b/homeassistant/components/bom/manifest.json
@@ -2,7 +2,7 @@
"domain": "bom",
"name": "Australian Bureau of Meteorology (BOM)",
"documentation": "https://www.home-assistant.io/integrations/bom",
- "requirements": ["bomradarloop==0.1.3"],
+ "requirements": ["bomradarloop==0.1.4"],
"dependencies": [],
- "codeowners": []
+ "codeowners": ["@maddenp"]
}
diff --git a/homeassistant/components/bom/sensor.py b/homeassistant/components/bom/sensor.py
index bd57e20edaa..5ecd9c1009f 100644
--- a/homeassistant/components/bom/sensor.py
+++ b/homeassistant/components/bom/sensor.py
@@ -19,14 +19,15 @@ from homeassistant.const import (
CONF_LONGITUDE,
CONF_MONITORED_CONDITIONS,
CONF_NAME,
+ SPEED_KILOMETERS_PER_HOUR,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle
import homeassistant.util.dt as dt_util
-_RESOURCE = "http://www.bom.gov.au/fwo/{}/{}.{}.json"
_LOGGER = logging.getLogger(__name__)
ATTR_LAST_UPDATE = "last_update"
@@ -59,7 +60,7 @@ SENSOR_TYPES = {
"cloud_type_id": ["Cloud Type ID", None],
"cloud_type": ["Cloud Type", None],
"delta_t": ["Delta Temp C", TEMP_CELSIUS],
- "gust_kmh": ["Wind Gust kmh", "km/h"],
+ "gust_kmh": ["Wind Gust kmh", SPEED_KILOMETERS_PER_HOUR],
"gust_kt": ["Wind Gust kt", "kt"],
"air_temp": ["Air Temp C", TEMP_CELSIUS],
"dewpt": ["Dew Point C", TEMP_CELSIUS],
@@ -68,7 +69,7 @@ SENSOR_TYPES = {
"press_msl": ["Pressure msl", "msl"],
"press_tend": ["Pressure Tend", None],
"rain_trace": ["Rain Today", "mm"],
- "rel_hum": ["Relative Humidity", "%"],
+ "rel_hum": ["Relative Humidity", UNIT_PERCENTAGE],
"sea_state": ["Sea State", None],
"swell_dir_worded": ["Swell Direction", None],
"swell_height": ["Swell Height", "m"],
@@ -76,7 +77,7 @@ SENSOR_TYPES = {
"vis_km": ["Visability km", "km"],
"weather": ["Weather", None],
"wind_dir": ["Wind Direction", None],
- "wind_spd_kmh": ["Wind Speed kmh", "km/h"],
+ "wind_spd_kmh": ["Wind Speed kmh", SPEED_KILOMETERS_PER_HOUR],
"wind_spd_kt": ["Wind Speed kt", "kt"],
}
@@ -158,9 +159,9 @@ class BOMCurrentSensor(Entity):
def name(self):
"""Return the name of the sensor."""
if self.stationname is None:
- return "BOM {}".format(SENSOR_TYPES[self._condition][0])
+ return f"BOM {SENSOR_TYPES[self._condition][0]}"
- return "BOM {} {}".format(self.stationname, SENSOR_TYPES[self._condition][0])
+ return f"BOM {self.stationname} {SENSOR_TYPES[self._condition][0]}"
@property
def state(self):
@@ -202,7 +203,10 @@ class BOMCurrentData:
def _build_url(self):
"""Build the URL for the requests."""
- url = _RESOURCE.format(self._zone_id, self._zone_id, self._wmo_id)
+ url = (
+ f"http://www.bom.gov.au/fwo/{self._zone_id}"
+ f"/{self._zone_id}.{self._wmo_id}.json"
+ )
_LOGGER.debug("BOM URL: %s", url)
return url
@@ -309,10 +313,10 @@ def _get_bom_stations():
r'(?P=zone)\.(?P\d\d\d\d\d).shtml">'
)
for state in ("nsw", "vic", "qld", "wa", "tas", "nt"):
- url = "http://www.bom.gov.au/{0}/observations/{0}all.shtml".format(state)
+ url = f"http://www.bom.gov.au/{state}/observations/{state}all.shtml"
for zone_id, wmo_id in re.findall(pattern, requests.get(url).text):
zones[wmo_id] = zone_id
- return {"{}.{}".format(zones[k], k): latlon[k] for k in set(latlon) & set(zones)}
+ return {f"{zones[k]}.{k}": latlon[k] for k in set(latlon) & set(zones)}
def bom_stations(cache_dir):
diff --git a/homeassistant/components/bom/weather.py b/homeassistant/components/bom/weather.py
index 2513c7c4c40..94b9960c851 100644
--- a/homeassistant/components/bom/weather.py
+++ b/homeassistant/components/bom/weather.py
@@ -49,7 +49,7 @@ class BOMWeather(WeatherEntity):
@property
def name(self):
"""Return the name of the sensor."""
- return "BOM {}".format(self.stationname or "(unknown station)")
+ return f"BOM {self.stationname or '(unknown station)'}"
@property
def condition(self):
diff --git a/homeassistant/components/braviatv/manifest.json b/homeassistant/components/braviatv/manifest.json
index 8bfa48b9195..4945614ed7f 100644
--- a/homeassistant/components/braviatv/manifest.json
+++ b/homeassistant/components/braviatv/manifest.json
@@ -2,7 +2,7 @@
"domain": "braviatv",
"name": "Sony Bravia TV",
"documentation": "https://www.home-assistant.io/integrations/braviatv",
- "requirements": ["bravia-tv==1.0", "getmac==0.8.1"],
+ "requirements": ["bravia-tv==1.0.1", "getmac==0.8.1"],
"dependencies": ["configurator"],
"codeowners": ["@robbiet480"]
}
diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py
index 67feb8bfc48..2916bb319f8 100644
--- a/homeassistant/components/braviatv/media_player.py
+++ b/homeassistant/components/braviatv/media_player.py
@@ -13,6 +13,7 @@ from homeassistant.components.media_player.const import (
SUPPORT_PLAY,
SUPPORT_PREVIOUS_TRACK,
SUPPORT_SELECT_SOURCE,
+ SUPPORT_STOP,
SUPPORT_TURN_OFF,
SUPPORT_TURN_ON,
SUPPORT_VOLUME_MUTE,
@@ -47,6 +48,7 @@ SUPPORT_BRAVIA = (
| SUPPORT_TURN_OFF
| SUPPORT_SELECT_SOURCE
| SUPPORT_PLAY
+ | SUPPORT_STOP
)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@@ -351,6 +353,11 @@ class BraviaTVDevice(MediaPlayerDevice):
self._playing = False
self._braviarc.media_pause()
+ def media_stop(self):
+ """Send media stop command to media player."""
+ self._playing = False
+ self._braviarc.media_stop()
+
def media_next_track(self):
"""Send next track command."""
self._braviarc.media_next_track()
diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py
index 96698e5b02f..714b5dfec34 100644
--- a/homeassistant/components/broadlink/remote.py
+++ b/homeassistant/components/broadlink/remote.py
@@ -44,9 +44,7 @@ DEFAULT_TIMEOUT = 5
SCAN_INTERVAL = timedelta(minutes=2)
-CODE_STORAGE_KEY = "broadlink_{}_codes"
CODE_STORAGE_VERSION = 1
-FLAG_STORAGE_KEY = "broadlink_{}_flags"
FLAG_STORAGE_VERSION = 1
FLAG_SAVE_DELAY = 15
@@ -96,8 +94,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
api = broadlink.rm((host, DEFAULT_PORT), mac_addr, None)
api.timeout = timeout
- code_storage = Store(hass, CODE_STORAGE_VERSION, CODE_STORAGE_KEY.format(unique_id))
- flag_storage = Store(hass, FLAG_STORAGE_VERSION, FLAG_STORAGE_KEY.format(unique_id))
+ code_storage = Store(hass, CODE_STORAGE_VERSION, f"broadlink_{unique_id}_codes")
+ flag_storage = Store(hass, FLAG_STORAGE_VERSION, f"broadlink_{unique_id}_flags")
remote = BroadlinkRemote(name, unique_id, api, code_storage, flag_storage)
connected, loaded = (False, False)
diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py
index 9f3087335c8..dbff4108a3f 100644
--- a/homeassistant/components/broadlink/sensor.py
+++ b/homeassistant/components/broadlink/sensor.py
@@ -15,6 +15,7 @@ from homeassistant.const import (
CONF_SCAN_INTERVAL,
CONF_TIMEOUT,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -29,7 +30,7 @@ SCAN_INTERVAL = timedelta(seconds=300)
SENSOR_TYPES = {
"temperature": ["Temperature", TEMP_CELSIUS],
"air_quality": ["Air Quality", " "],
- "humidity": ["Humidity", "%"],
+ "humidity": ["Humidity", UNIT_PERCENTAGE],
"light": ["Light", " "],
"noise": ["Noise", " "],
}
@@ -67,7 +68,7 @@ class BroadlinkSensor(Entity):
def __init__(self, name, broadlink_data, sensor_type):
"""Initialize the sensor."""
- self._name = "{} {}".format(name, SENSOR_TYPES[sensor_type][0])
+ self._name = f"{name} {SENSOR_TYPES[sensor_type][0]}"
self._state = None
self._is_available = False
self._type = sensor_type
diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py
index 78738870aaa..9b986ae75d4 100644
--- a/homeassistant/components/broadlink/switch.py
+++ b/homeassistant/components/broadlink/switch.py
@@ -7,11 +7,7 @@ import socket
import broadlink
import voluptuous as vol
-from homeassistant.components.switch import (
- ENTITY_ID_FORMAT,
- PLATFORM_SCHEMA,
- SwitchDevice,
-)
+from homeassistant.components.switch import DOMAIN, PLATFORM_SCHEMA, SwitchDevice
from homeassistant.const import (
CONF_COMMAND_OFF,
CONF_COMMAND_ON,
@@ -159,7 +155,7 @@ class BroadlinkRMSwitch(SwitchDevice, RestoreEntity):
self, name, friendly_name, device, command_on, command_off, retry_times
):
"""Initialize the switch."""
- self.entity_id = ENTITY_ID_FORMAT.format(slugify(name))
+ self.entity_id = f"{DOMAIN}.{slugify(name)}"
self._name = friendly_name
self._state = False
self._command_on = command_on
diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py
index fdb7cd82b9c..94d88162d76 100644
--- a/homeassistant/components/brother/const.py
+++ b/homeassistant/components/brother/const.py
@@ -1,18 +1,30 @@
"""Constants for Brother integration."""
+from homeassistant.const import TIME_DAYS, UNIT_PERCENTAGE
+
ATTR_BELT_UNIT_REMAINING_LIFE = "belt_unit_remaining_life"
+ATTR_BLACK_DRUM_COUNTER = "black_drum_counter"
+ATTR_BLACK_DRUM_REMAINING_LIFE = "black_drum_remaining_life"
+ATTR_BLACK_DRUM_REMAINING_PAGES = "black_drum_remaining_pages"
ATTR_BLACK_INK_REMAINING = "black_ink_remaining"
ATTR_BLACK_TONER_REMAINING = "black_toner_remaining"
ATTR_BW_COUNTER = "b/w_counter"
ATTR_COLOR_COUNTER = "color_counter"
+ATTR_CYAN_DRUM_COUNTER = "cyan_drum_counter"
+ATTR_CYAN_DRUM_REMAINING_LIFE = "cyan_drum_remaining_life"
+ATTR_CYAN_DRUM_REMAINING_PAGES = "cyan_drum_remaining_pages"
ATTR_CYAN_INK_REMAINING = "cyan_ink_remaining"
ATTR_CYAN_TONER_REMAINING = "cyan_toner_remaining"
ATTR_DRUM_COUNTER = "drum_counter"
ATTR_DRUM_REMAINING_LIFE = "drum_remaining_life"
ATTR_DRUM_REMAINING_PAGES = "drum_remaining_pages"
+ATTR_DUPLEX_COUNTER = "duplex_unit_pages_counter"
ATTR_FUSER_REMAINING_LIFE = "fuser_remaining_life"
ATTR_ICON = "icon"
ATTR_LABEL = "label"
ATTR_LASER_REMAINING_LIFE = "laser_remaining_life"
+ATTR_MAGENTA_DRUM_COUNTER = "magenta_drum_counter"
+ATTR_MAGENTA_DRUM_REMAINING_LIFE = "magenta_drum_remaining_life"
+ATTR_MAGENTA_DRUM_REMAINING_PAGES = "magenta_drum_remaining_pages"
ATTR_MAGENTA_INK_REMAINING = "magenta_ink_remaining"
ATTR_MAGENTA_TONER_REMAINING = "magenta_toner_remaining"
ATTR_MANUFACTURER = "Brother"
@@ -22,14 +34,15 @@ ATTR_PF_KIT_MP_REMAINING_LIFE = "pf_kit_mp_remaining_life"
ATTR_STATUS = "status"
ATTR_UNIT = "unit"
ATTR_UPTIME = "uptime"
+ATTR_YELLOW_DRUM_COUNTER = "yellow_drum_counter"
+ATTR_YELLOW_DRUM_REMAINING_LIFE = "yellow_drum_remaining_life"
+ATTR_YELLOW_DRUM_REMAINING_PAGES = "yellow_drum_remaining_pages"
ATTR_YELLOW_INK_REMAINING = "yellow_ink_remaining"
ATTR_YELLOW_TONER_REMAINING = "yellow_toner_remaining"
DOMAIN = "brother"
UNIT_PAGES = "p"
-UNIT_DAYS = "days"
-UNIT_PERCENT = "%"
PRINTER_TYPES = ["laser", "ink"]
@@ -54,79 +67,104 @@ SENSOR_TYPES = {
ATTR_LABEL: ATTR_COLOR_COUNTER.replace("_", " ").title(),
ATTR_UNIT: UNIT_PAGES,
},
+ ATTR_DUPLEX_COUNTER: {
+ ATTR_ICON: "mdi:file-document-outline",
+ ATTR_LABEL: ATTR_DUPLEX_COUNTER.replace("_", " ").title(),
+ ATTR_UNIT: UNIT_PAGES,
+ },
ATTR_DRUM_REMAINING_LIFE: {
ATTR_ICON: "mdi:chart-donut",
ATTR_LABEL: ATTR_DRUM_REMAINING_LIFE.replace("_", " ").title(),
- ATTR_UNIT: UNIT_PERCENT,
+ ATTR_UNIT: UNIT_PERCENTAGE,
+ },
+ ATTR_BLACK_DRUM_REMAINING_LIFE: {
+ ATTR_ICON: "mdi:chart-donut",
+ ATTR_LABEL: ATTR_BLACK_DRUM_REMAINING_LIFE.replace("_", " ").title(),
+ ATTR_UNIT: UNIT_PERCENTAGE,
+ },
+ ATTR_CYAN_DRUM_REMAINING_LIFE: {
+ ATTR_ICON: "mdi:chart-donut",
+ ATTR_LABEL: ATTR_CYAN_DRUM_REMAINING_LIFE.replace("_", " ").title(),
+ ATTR_UNIT: UNIT_PERCENTAGE,
+ },
+ ATTR_MAGENTA_DRUM_REMAINING_LIFE: {
+ ATTR_ICON: "mdi:chart-donut",
+ ATTR_LABEL: ATTR_MAGENTA_DRUM_REMAINING_LIFE.replace("_", " ").title(),
+ ATTR_UNIT: UNIT_PERCENTAGE,
+ },
+ ATTR_YELLOW_DRUM_REMAINING_LIFE: {
+ ATTR_ICON: "mdi:chart-donut",
+ ATTR_LABEL: ATTR_YELLOW_DRUM_REMAINING_LIFE.replace("_", " ").title(),
+ ATTR_UNIT: UNIT_PERCENTAGE,
},
ATTR_BELT_UNIT_REMAINING_LIFE: {
ATTR_ICON: "mdi:current-ac",
ATTR_LABEL: ATTR_BELT_UNIT_REMAINING_LIFE.replace("_", " ").title(),
- ATTR_UNIT: UNIT_PERCENT,
+ ATTR_UNIT: UNIT_PERCENTAGE,
},
ATTR_FUSER_REMAINING_LIFE: {
ATTR_ICON: "mdi:water-outline",
ATTR_LABEL: ATTR_FUSER_REMAINING_LIFE.replace("_", " ").title(),
- ATTR_UNIT: UNIT_PERCENT,
+ ATTR_UNIT: UNIT_PERCENTAGE,
},
ATTR_LASER_REMAINING_LIFE: {
ATTR_ICON: "mdi:spotlight-beam",
ATTR_LABEL: ATTR_LASER_REMAINING_LIFE.replace("_", " ").title(),
- ATTR_UNIT: UNIT_PERCENT,
+ ATTR_UNIT: UNIT_PERCENTAGE,
},
ATTR_PF_KIT_1_REMAINING_LIFE: {
ATTR_ICON: "mdi:printer-3d",
ATTR_LABEL: ATTR_PF_KIT_1_REMAINING_LIFE.replace("_", " ").title(),
- ATTR_UNIT: UNIT_PERCENT,
+ ATTR_UNIT: UNIT_PERCENTAGE,
},
ATTR_PF_KIT_MP_REMAINING_LIFE: {
ATTR_ICON: "mdi:printer-3d",
ATTR_LABEL: ATTR_PF_KIT_MP_REMAINING_LIFE.replace("_", " ").title(),
- ATTR_UNIT: UNIT_PERCENT,
+ ATTR_UNIT: UNIT_PERCENTAGE,
},
ATTR_BLACK_TONER_REMAINING: {
ATTR_ICON: "mdi:printer-3d-nozzle",
ATTR_LABEL: ATTR_BLACK_TONER_REMAINING.replace("_", " ").title(),
- ATTR_UNIT: UNIT_PERCENT,
+ ATTR_UNIT: UNIT_PERCENTAGE,
},
ATTR_CYAN_TONER_REMAINING: {
ATTR_ICON: "mdi:printer-3d-nozzle",
ATTR_LABEL: ATTR_CYAN_TONER_REMAINING.replace("_", " ").title(),
- ATTR_UNIT: UNIT_PERCENT,
+ ATTR_UNIT: UNIT_PERCENTAGE,
},
ATTR_MAGENTA_TONER_REMAINING: {
ATTR_ICON: "mdi:printer-3d-nozzle",
ATTR_LABEL: ATTR_MAGENTA_TONER_REMAINING.replace("_", " ").title(),
- ATTR_UNIT: UNIT_PERCENT,
+ ATTR_UNIT: UNIT_PERCENTAGE,
},
ATTR_YELLOW_TONER_REMAINING: {
ATTR_ICON: "mdi:printer-3d-nozzle",
ATTR_LABEL: ATTR_YELLOW_TONER_REMAINING.replace("_", " ").title(),
- ATTR_UNIT: UNIT_PERCENT,
+ ATTR_UNIT: UNIT_PERCENTAGE,
},
ATTR_BLACK_INK_REMAINING: {
ATTR_ICON: "mdi:printer-3d-nozzle",
ATTR_LABEL: ATTR_BLACK_INK_REMAINING.replace("_", " ").title(),
- ATTR_UNIT: UNIT_PERCENT,
+ ATTR_UNIT: UNIT_PERCENTAGE,
},
ATTR_CYAN_INK_REMAINING: {
ATTR_ICON: "mdi:printer-3d-nozzle",
ATTR_LABEL: ATTR_CYAN_INK_REMAINING.replace("_", " ").title(),
- ATTR_UNIT: UNIT_PERCENT,
+ ATTR_UNIT: UNIT_PERCENTAGE,
},
ATTR_MAGENTA_INK_REMAINING: {
ATTR_ICON: "mdi:printer-3d-nozzle",
ATTR_LABEL: ATTR_MAGENTA_INK_REMAINING.replace("_", " ").title(),
- ATTR_UNIT: UNIT_PERCENT,
+ ATTR_UNIT: UNIT_PERCENTAGE,
},
ATTR_YELLOW_INK_REMAINING: {
ATTR_ICON: "mdi:printer-3d-nozzle",
ATTR_LABEL: ATTR_YELLOW_INK_REMAINING.replace("_", " ").title(),
- ATTR_UNIT: UNIT_PERCENT,
+ ATTR_UNIT: UNIT_PERCENTAGE,
},
ATTR_UPTIME: {
ATTR_ICON: "mdi:timer",
ATTR_LABEL: ATTR_UPTIME.title(),
- ATTR_UNIT: UNIT_DAYS,
+ ATTR_UNIT: TIME_DAYS,
},
}
diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json
index 51e6c3284ff..ec87adacb5f 100644
--- a/homeassistant/components/brother/manifest.json
+++ b/homeassistant/components/brother/manifest.json
@@ -4,7 +4,7 @@
"documentation": "https://www.home-assistant.io/integrations/brother",
"dependencies": [],
"codeowners": ["@bieniu"],
- "requirements": ["brother==0.1.6"],
+ "requirements": ["brother==0.1.8"],
"zeroconf": ["_printer._tcp.local."],
"config_flow": true
}
diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py
index 9ad075f81cd..e118e65e9a5 100644
--- a/homeassistant/components/brother/sensor.py
+++ b/homeassistant/components/brother/sensor.py
@@ -4,17 +4,32 @@ import logging
from homeassistant.helpers.entity import Entity
from .const import (
+ ATTR_BLACK_DRUM_COUNTER,
+ ATTR_BLACK_DRUM_REMAINING_LIFE,
+ ATTR_BLACK_DRUM_REMAINING_PAGES,
+ ATTR_CYAN_DRUM_COUNTER,
+ ATTR_CYAN_DRUM_REMAINING_LIFE,
+ ATTR_CYAN_DRUM_REMAINING_PAGES,
ATTR_DRUM_COUNTER,
ATTR_DRUM_REMAINING_LIFE,
ATTR_DRUM_REMAINING_PAGES,
ATTR_ICON,
ATTR_LABEL,
+ ATTR_MAGENTA_DRUM_COUNTER,
+ ATTR_MAGENTA_DRUM_REMAINING_LIFE,
+ ATTR_MAGENTA_DRUM_REMAINING_PAGES,
ATTR_MANUFACTURER,
ATTR_UNIT,
+ ATTR_YELLOW_DRUM_COUNTER,
+ ATTR_YELLOW_DRUM_REMAINING_LIFE,
+ ATTR_YELLOW_DRUM_REMAINING_PAGES,
DOMAIN,
SENSOR_TYPES,
)
+ATTR_COUNTER = "counter"
+ATTR_REMAINING_PAGES = "remaining_pages"
+
_LOGGER = logging.getLogger(__name__)
@@ -65,11 +80,26 @@ class BrotherPrinterSensor(Entity):
@property
def device_state_attributes(self):
"""Return the state attributes."""
+ remaining_pages = None
+ drum_counter = None
if self.kind == ATTR_DRUM_REMAINING_LIFE:
- self._attrs["remaining_pages"] = self.printer.data.get(
- ATTR_DRUM_REMAINING_PAGES
- )
- self._attrs["counter"] = self.printer.data.get(ATTR_DRUM_COUNTER)
+ remaining_pages = ATTR_DRUM_REMAINING_PAGES
+ drum_counter = ATTR_DRUM_COUNTER
+ elif self.kind == ATTR_BLACK_DRUM_REMAINING_LIFE:
+ remaining_pages = ATTR_BLACK_DRUM_REMAINING_PAGES
+ drum_counter = ATTR_BLACK_DRUM_COUNTER
+ elif self.kind == ATTR_CYAN_DRUM_REMAINING_LIFE:
+ remaining_pages = ATTR_CYAN_DRUM_REMAINING_PAGES
+ drum_counter = ATTR_CYAN_DRUM_COUNTER
+ elif self.kind == ATTR_MAGENTA_DRUM_REMAINING_LIFE:
+ remaining_pages = ATTR_MAGENTA_DRUM_REMAINING_PAGES
+ drum_counter = ATTR_MAGENTA_DRUM_COUNTER
+ elif self.kind == ATTR_YELLOW_DRUM_REMAINING_LIFE:
+ 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)
return self._attrs
@property
diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py
index 282433aa7a4..feb066a6f3f 100644
--- a/homeassistant/components/brottsplatskartan/sensor.py
+++ b/homeassistant/components/brottsplatskartan/sensor.py
@@ -69,7 +69,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
# Every Home Assistant instance should have their own unique
# app parameter: https://brottsplatskartan.se/sida/api
- app = "ha-{}".format(uuid.getnode())
+ app = f"ha-{uuid.getnode()}"
bpk = brottsplatskartan.BrottsplatsKartan(
app=app, area=area, latitude=latitude, longitude=longitude
diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py
index 373c3339441..b3a007277c3 100644
--- a/homeassistant/components/brunt/cover.py
+++ b/homeassistant/components/brunt/cover.py
@@ -56,9 +56,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
except (TypeError, KeyError, NameError, ValueError) as ex:
_LOGGER.error("%s", ex)
hass.components.persistent_notification.create(
- "Error: {}
"
- "You will need to restart hass after fixing."
- "".format(ex),
+ "Error: {ex}
You will need to restart hass after fixing.",
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py
index b41b3220b40..b685bdb5c73 100644
--- a/homeassistant/components/buienradar/camera.py
+++ b/homeassistant/components/buienradar/camera.py
@@ -17,8 +17,6 @@ CONF_DIMENSION = "dimension"
CONF_DELTA = "delta"
CONF_COUNTRY = "country_code"
-RADAR_MAP_URL_TEMPLATE = "https://api.buienradar.nl/image/1.0/RadarMap{c}?w={w}&h={h}"
-
_LOG = logging.getLogger(__name__)
# Maximum range according to docs
@@ -112,8 +110,9 @@ class BuienradarCam(Camera):
"""Retrieve new radar image and return whether this succeeded."""
session = async_get_clientsession(self.hass)
- url = RADAR_MAP_URL_TEMPLATE.format(
- c=self._country, w=self._dimension, h=self._dimension
+ url = (
+ f"https://api.buienradar.nl/image/1.0/RadarMap{self._country}"
+ f"?w={self._dimension}&h={self._dimension}"
)
if self._last_modified:
diff --git a/homeassistant/components/buienradar/manifest.json b/homeassistant/components/buienradar/manifest.json
index 23e282c34bb..5f604322b16 100644
--- a/homeassistant/components/buienradar/manifest.json
+++ b/homeassistant/components/buienradar/manifest.json
@@ -2,7 +2,7 @@
"domain": "buienradar",
"name": "Buienradar",
"documentation": "https://www.home-assistant.io/integrations/buienradar",
- "requirements": ["buienradar==1.0.1"],
+ "requirements": ["buienradar==1.0.4"],
"dependencies": [],
"codeowners": ["@mjj4791", "@ties"]
}
diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py
index f642fc2e249..5d709ab1e63 100644
--- a/homeassistant/components/buienradar/sensor.py
+++ b/homeassistant/components/buienradar/sensor.py
@@ -27,7 +27,11 @@ from homeassistant.const import (
CONF_LONGITUDE,
CONF_MONITORED_CONDITIONS,
CONF_NAME,
+ IRRADIATION_WATTS_PER_SQUARE_METER,
+ SPEED_KILOMETERS_PER_HOUR,
TEMP_CELSIUS,
+ TIME_HOURS,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -64,21 +68,21 @@ SENSOR_TYPES = {
"symbol": ["Symbol", None, None],
# new in json api (>1.0.0):
"feeltemperature": ["Feel temperature", TEMP_CELSIUS, "mdi:thermometer"],
- "humidity": ["Humidity", "%", "mdi:water-percent"],
+ "humidity": ["Humidity", UNIT_PERCENTAGE, "mdi:water-percent"],
"temperature": ["Temperature", TEMP_CELSIUS, "mdi:thermometer"],
"groundtemperature": ["Ground temperature", TEMP_CELSIUS, "mdi:thermometer"],
- "windspeed": ["Wind speed", "km/h", "mdi:weather-windy"],
+ "windspeed": ["Wind speed", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"],
"windforce": ["Wind force", "Bft", "mdi:weather-windy"],
"winddirection": ["Wind direction", None, "mdi:compass-outline"],
"windazimuth": ["Wind direction azimuth", "°", "mdi:compass-outline"],
"pressure": ["Pressure", "hPa", "mdi:gauge"],
"visibility": ["Visibility", "km", None],
- "windgust": ["Wind gust", "km/h", "mdi:weather-windy"],
- "precipitation": ["Precipitation", "mm/h", "mdi:weather-pouring"],
- "irradiance": ["Irradiance", "W/m2", "mdi:sunglasses"],
+ "windgust": ["Wind gust", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"],
+ "precipitation": ["Precipitation", f"mm/{TIME_HOURS}", "mdi:weather-pouring"],
+ "irradiance": ["Irradiance", IRRADIATION_WATTS_PER_SQUARE_METER, "mdi:sunglasses"],
"precipitation_forecast_average": [
"Precipitation forecast average",
- "mm/h",
+ f"mm/{TIME_HOURS}",
"mdi:weather-pouring",
],
"precipitation_forecast_total": [
@@ -117,26 +121,26 @@ SENSOR_TYPES = {
"maxrain_3d": ["Maximum rain 3d", "mm", "mdi:weather-pouring"],
"maxrain_4d": ["Maximum rain 4d", "mm", "mdi:weather-pouring"],
"maxrain_5d": ["Maximum rain 5d", "mm", "mdi:weather-pouring"],
- "rainchance_1d": ["Rainchance 1d", "%", "mdi:weather-pouring"],
- "rainchance_2d": ["Rainchance 2d", "%", "mdi:weather-pouring"],
- "rainchance_3d": ["Rainchance 3d", "%", "mdi:weather-pouring"],
- "rainchance_4d": ["Rainchance 4d", "%", "mdi:weather-pouring"],
- "rainchance_5d": ["Rainchance 5d", "%", "mdi:weather-pouring"],
- "sunchance_1d": ["Sunchance 1d", "%", "mdi:weather-partly-cloudy"],
- "sunchance_2d": ["Sunchance 2d", "%", "mdi:weather-partly-cloudy"],
- "sunchance_3d": ["Sunchance 3d", "%", "mdi:weather-partly-cloudy"],
- "sunchance_4d": ["Sunchance 4d", "%", "mdi:weather-partly-cloudy"],
- "sunchance_5d": ["Sunchance 5d", "%", "mdi:weather-partly-cloudy"],
+ "rainchance_1d": ["Rainchance 1d", UNIT_PERCENTAGE, "mdi:weather-pouring"],
+ "rainchance_2d": ["Rainchance 2d", UNIT_PERCENTAGE, "mdi:weather-pouring"],
+ "rainchance_3d": ["Rainchance 3d", UNIT_PERCENTAGE, "mdi:weather-pouring"],
+ "rainchance_4d": ["Rainchance 4d", UNIT_PERCENTAGE, "mdi:weather-pouring"],
+ "rainchance_5d": ["Rainchance 5d", UNIT_PERCENTAGE, "mdi:weather-pouring"],
+ "sunchance_1d": ["Sunchance 1d", UNIT_PERCENTAGE, "mdi:weather-partly-cloudy"],
+ "sunchance_2d": ["Sunchance 2d", UNIT_PERCENTAGE, "mdi:weather-partly-cloudy"],
+ "sunchance_3d": ["Sunchance 3d", UNIT_PERCENTAGE, "mdi:weather-partly-cloudy"],
+ "sunchance_4d": ["Sunchance 4d", UNIT_PERCENTAGE, "mdi:weather-partly-cloudy"],
+ "sunchance_5d": ["Sunchance 5d", UNIT_PERCENTAGE, "mdi:weather-partly-cloudy"],
"windforce_1d": ["Wind force 1d", "Bft", "mdi:weather-windy"],
"windforce_2d": ["Wind force 2d", "Bft", "mdi:weather-windy"],
"windforce_3d": ["Wind force 3d", "Bft", "mdi:weather-windy"],
"windforce_4d": ["Wind force 4d", "Bft", "mdi:weather-windy"],
"windforce_5d": ["Wind force 5d", "Bft", "mdi:weather-windy"],
- "windspeed_1d": ["Wind speed 1d", "km/h", "mdi:weather-windy"],
- "windspeed_2d": ["Wind speed 2d", "km/h", "mdi:weather-windy"],
- "windspeed_3d": ["Wind speed 3d", "km/h", "mdi:weather-windy"],
- "windspeed_4d": ["Wind speed 4d", "km/h", "mdi:weather-windy"],
- "windspeed_5d": ["Wind speed 5d", "km/h", "mdi:weather-windy"],
+ "windspeed_1d": ["Wind speed 1d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"],
+ "windspeed_2d": ["Wind speed 2d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"],
+ "windspeed_3d": ["Wind speed 3d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"],
+ "windspeed_4d": ["Wind speed 4d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"],
+ "windspeed_5d": ["Wind speed 5d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"],
"winddirection_1d": ["Wind direction 1d", None, "mdi:compass-outline"],
"winddirection_2d": ["Wind direction 2d", None, "mdi:compass-outline"],
"winddirection_3d": ["Wind direction 3d", None, "mdi:compass-outline"],
diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py
index 37c518cef7a..7d16f072b98 100644
--- a/homeassistant/components/buienradar/util.py
+++ b/homeassistant/components/buienradar/util.py
@@ -32,12 +32,31 @@ from homeassistant.util import dt as dt_util
from .const import SCHEDULE_NOK, SCHEDULE_OK
+__all__ = ["BrData"]
_LOGGER = logging.getLogger(__name__)
+"""
+Log at WARN level after WARN_THRESHOLD failures, otherwise log at
+DEBUG level.
+"""
+WARN_THRESHOLD = 4
+
+
+def threshold_log(count: int, *args, **kwargs) -> None:
+ """Log at warn level after WARN_THRESHOLD failures, debug otherwise."""
+ if count >= WARN_THRESHOLD:
+ _LOGGER.warning(*args, **kwargs)
+ else:
+ _LOGGER.debug(*args, **kwargs)
+
class BrData:
"""Get the latest data and updates the states."""
+ # Initialize to warn immediately if the first call fails.
+ load_error_count: int = WARN_THRESHOLD
+ rain_error_count: int = WARN_THRESHOLD
+
def __init__(self, hass, coordinates, timeframe, devices):
"""Initialize the data object."""
self.devices = devices
@@ -96,7 +115,9 @@ class BrData:
if content.get(SUCCESS) is not True:
# unable to get the data
- _LOGGER.warning(
+ self.load_error_count += 1
+ threshold_log(
+ self.load_error_count,
"Unable to retrieve json data from Buienradar."
"(Msg: %s, status: %s,)",
content.get(MESSAGE),
@@ -105,6 +126,7 @@ class BrData:
# schedule new call
await self.schedule_update(SCHEDULE_NOK)
return
+ self.load_error_count = 0
# rounding coordinates prevents unnecessary redirects/calls
lat = self.coordinates[CONF_LATITUDE]
@@ -113,15 +135,18 @@ class BrData:
raincontent = await self.get_data(rainurl)
if raincontent.get(SUCCESS) is not True:
+ self.rain_error_count += 1
# unable to get the data
- _LOGGER.warning(
- "Unable to retrieve raindata from Buienradar. (Msg: %s, status: %s)",
+ threshold_log(
+ self.rain_error_count,
+ "Unable to retrieve rain data from Buienradar." "(Msg: %s, status: %s)",
raincontent.get(MESSAGE),
raincontent.get(STATUS_CODE),
)
# schedule new call
await self.schedule_update(SCHEDULE_NOK)
return
+ self.rain_error_count = 0
result = parse_data(
content.get(CONTENT),
diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py
index 98cbb2f5e43..32e8babde90 100644
--- a/homeassistant/components/buienradar/weather.py
+++ b/homeassistant/components/buienradar/weather.py
@@ -113,8 +113,8 @@ class BrWeather(WeatherEntity):
@property
def name(self):
"""Return the name of the sensor."""
- return self._stationname or "BR {}".format(
- self._data.stationname or "(unknown station)"
+ return (
+ self._stationname or f"BR {self._data.stationname or '(unknown station)'}"
)
@property
diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py
index 7cdf69a0c33..579755709d1 100644
--- a/homeassistant/components/caldav/calendar.py
+++ b/homeassistant/components/caldav/calendar.py
@@ -88,9 +88,7 @@ def setup_platform(hass, config, add_entities, disc_info=None):
continue
name = cust_calendar[CONF_NAME]
- device_id = "{} {}".format(
- cust_calendar[CONF_CALENDAR], cust_calendar[CONF_NAME]
- )
+ device_id = f"{cust_calendar[CONF_CALENDAR]} {cust_calendar[CONF_NAME]}"
entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass)
calendar_devices.append(
WebDavCalendarEventDevice(
diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py
index 647e54556c4..5d9bc99f945 100644
--- a/homeassistant/components/camera/__init__.py
+++ b/homeassistant/components/camera/__init__.py
@@ -136,7 +136,13 @@ async def async_request_stream(hass, entity_id, fmt):
f"{camera.entity_id} does not support play stream service"
)
- return request_stream(hass, source, fmt=fmt, keepalive=camera_prefs.preload_stream)
+ return request_stream(
+ hass,
+ source,
+ fmt=fmt,
+ keepalive=camera_prefs.preload_stream,
+ options=camera.options,
+ )
@bind_hass
@@ -256,7 +262,7 @@ async def async_setup(hass, config):
if not source:
continue
- request_stream(hass, source, keepalive=True)
+ request_stream(hass, source, keepalive=True, options=camera.stream_options)
async_when_setup(hass, DOMAIN_STREAM, preload_stream)
@@ -312,6 +318,7 @@ class Camera(Entity):
def __init__(self):
"""Initialize a camera."""
self.is_streaming = False
+ self.stream_options = {}
self.content_type = DEFAULT_CONTENT_TYPE
self.access_tokens: collections.deque = collections.deque([], 2)
self.async_update_token()
@@ -535,6 +542,7 @@ async def websocket_camera_thumbnail(hass, connection, msg):
Async friendly.
"""
+ _LOGGER.warning("The websocket command 'camera_thumbnail' has been deprecated.")
try:
image = await async_get_image(hass, msg["entity_id"])
await connection.send_big_result(
@@ -580,7 +588,11 @@ async def ws_camera_stream(hass, connection, msg):
fmt = msg["format"]
url = request_stream(
- hass, source, fmt=fmt, keepalive=camera_prefs.preload_stream
+ hass,
+ source,
+ fmt=fmt,
+ keepalive=camera_prefs.preload_stream,
+ options=camera.stream_options,
)
connection.send_result(msg["id"], {"url": url})
except HomeAssistantError as ex:
@@ -665,7 +677,13 @@ async def async_handle_play_stream_service(camera, service_call):
fmt = service_call.data[ATTR_FORMAT]
entity_ids = service_call.data[ATTR_MEDIA_PLAYER]
- url = request_stream(hass, source, fmt=fmt, keepalive=camera_prefs.preload_stream)
+ url = request_stream(
+ hass,
+ source,
+ fmt=fmt,
+ keepalive=camera_prefs.preload_stream,
+ options=camera.stream_options,
+ )
data = {
ATTR_ENTITY_ID: entity_ids,
ATTR_MEDIA_CONTENT_ID: f"{hass.config.api.base_url}{url}",
diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml
index c50e2926a3f..6196322e234 100644
--- a/homeassistant/components/camera/services.yaml
+++ b/homeassistant/components/camera/services.yaml
@@ -68,18 +68,3 @@ record:
description: (Optional) Target lookback period (in seconds) to include in addition to duration. Only available if there is currently an active HLS stream.
example: 4
-onvif_ptz:
- description: Pan/Tilt/Zoom service for ONVIF camera.
- fields:
- entity_id:
- description: Name(s) of entities to pan, tilt or zoom.
- example: 'camera.living_room_camera'
- pan:
- description: "Direction of pan. Allowed values: LEFT, RIGHT."
- example: 'LEFT'
- tilt:
- description: "Direction of tilt. Allowed values: DOWN, UP."
- example: 'DOWN'
- zoom:
- description: "Zoom. Allowed values: ZOOM_IN, ZOOM_OUT"
- example: "ZOOM_IN"
diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py
index cceb78743d3..35fff8accbd 100644
--- a/homeassistant/components/canary/alarm_control_panel.py
+++ b/homeassistant/components/canary/alarm_control_panel.py
@@ -49,7 +49,6 @@ class CanaryAlarm(AlarmControlPanel):
@property
def state(self):
"""Return the state of the device."""
-
location = self._data.get_location(self._location_id)
if location.is_private:
@@ -82,15 +81,16 @@ class CanaryAlarm(AlarmControlPanel):
def alarm_arm_home(self, code=None):
"""Send arm home command."""
-
self._data.set_location_mode(self._location_id, LOCATION_MODE_HOME)
def alarm_arm_away(self, code=None):
"""Send arm away command."""
-
self._data.set_location_mode(self._location_id, LOCATION_MODE_AWAY)
def alarm_arm_night(self, code=None):
"""Send arm night command."""
-
self._data.set_location_mode(self._location_id, LOCATION_MODE_NIGHT)
+
+ def update(self):
+ """Get the latest state of the sensor."""
+ self._data.update()
diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py
index 67654c99f3e..88b42d296ed 100644
--- a/homeassistant/components/canary/sensor.py
+++ b/homeassistant/components/canary/sensor.py
@@ -1,7 +1,7 @@
"""Support for Canary sensors."""
from canary.api import SensorType
-from homeassistant.const import TEMP_CELSIUS
+from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.icon import icon_for_battery_level
@@ -10,14 +10,21 @@ from . import DATA_CANARY
SENSOR_VALUE_PRECISION = 2
ATTR_AIR_QUALITY = "air_quality"
+# Define variables to store the device names, as referred to by the Canary API.
+# Note: If Canary change the name of any of their devices (which they have done),
+# then these variables will need updating, otherwise the sensors will stop working
+# and disappear in Home Assistant.
+CANARY_PRO = "Canary Pro"
+CANARY_FLEX = "Canary Flex"
+
# Sensor types are defined like so:
# sensor type name, unit_of_measurement, icon
SENSOR_TYPES = [
- ["temperature", TEMP_CELSIUS, "mdi:thermometer", ["Canary"]],
- ["humidity", "%", "mdi:water-percent", ["Canary"]],
- ["air_quality", None, "mdi:weather-windy", ["Canary"]],
- ["wifi", "dBm", "mdi:wifi", ["Canary Flex"]],
- ["battery", "%", "mdi:battery-50", ["Canary Flex"]],
+ ["temperature", TEMP_CELSIUS, "mdi:thermometer", [CANARY_PRO]],
+ ["humidity", UNIT_PERCENTAGE, "mdi:water-percent", [CANARY_PRO]],
+ ["air_quality", None, "mdi:weather-windy", [CANARY_PRO]],
+ ["wifi", "dBm", "mdi:wifi", [CANARY_FLEX]],
+ ["battery", UNIT_PERCENTAGE, "mdi:battery-50", [CANARY_FLEX]],
]
STATE_AIR_QUALITY_NORMAL = "normal"
diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json
index 51558e78266..be0b64dc0b1 100644
--- a/homeassistant/components/cast/manifest.json
+++ b/homeassistant/components/cast/manifest.json
@@ -3,7 +3,7 @@
"name": "Google Cast",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/cast",
- "requirements": ["pychromecast==4.1.1"],
+ "requirements": ["pychromecast==4.2.0"],
"dependencies": [],
"after_dependencies": ["cloud"],
"zeroconf": ["_googlecast._tcp.local."],
diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py
index 03174134502..4e259038f14 100644
--- a/homeassistant/components/cast/media_player.py
+++ b/homeassistant/components/cast/media_player.py
@@ -66,7 +66,7 @@ from .helpers import (
_LOGGER = logging.getLogger(__name__)
CONF_IGNORE_CEC = "ignore_cec"
-CAST_SPLASH = "https://home-assistant.io/images/cast/splash.png"
+CAST_SPLASH = "https://www.home-assistant.io/images/cast/splash.png"
SUPPORT_CAST = (
SUPPORT_PAUSE
diff --git a/homeassistant/components/cert_expiry/.translations/en.json b/homeassistant/components/cert_expiry/.translations/en.json
index 19e237a6d05..1c1b9a882e3 100644
--- a/homeassistant/components/cert_expiry/.translations/en.json
+++ b/homeassistant/components/cert_expiry/.translations/en.json
@@ -1,11 +1,14 @@
{
"config": {
"abort": {
- "host_port_exists": "This host and port combination is already configured"
+ "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",
diff --git a/homeassistant/components/cert_expiry/.translations/es.json b/homeassistant/components/cert_expiry/.translations/es.json
index 4432edac563..628f2b22e21 100644
--- a/homeassistant/components/cert_expiry/.translations/es.json
+++ b/homeassistant/components/cert_expiry/.translations/es.json
@@ -1,11 +1,14 @@
{
"config": {
"abort": {
- "host_port_exists": "Esta combinaci\u00f3n de host y puerto ya est\u00e1 configurada"
+ "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",
diff --git a/homeassistant/components/cert_expiry/.translations/no.json b/homeassistant/components/cert_expiry/.translations/no.json
index fc2e98b725d..e5faab74995 100644
--- a/homeassistant/components/cert_expiry/.translations/no.json
+++ b/homeassistant/components/cert_expiry/.translations/no.json
@@ -1,11 +1,14 @@
{
"config": {
"abort": {
- "host_port_exists": "Denne verts- og portkombinasjonen er allerede konfigurert"
+ "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",
diff --git a/homeassistant/components/cert_expiry/.translations/ru.json b/homeassistant/components/cert_expiry/.translations/ru.json
index 8c0f230382a..04a41704500 100644
--- a/homeassistant/components/cert_expiry/.translations/ru.json
+++ b/homeassistant/components/cert_expiry/.translations/ru.json
@@ -1,11 +1,14 @@
{
"config": {
"abort": {
- "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."
+ "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.",
diff --git a/homeassistant/components/cert_expiry/.translations/zh-Hant.json b/homeassistant/components/cert_expiry/.translations/zh-Hant.json
index a14361376df..833e2370dde 100644
--- a/homeassistant/components/cert_expiry/.translations/zh-Hant.json
+++ b/homeassistant/components/cert_expiry/.translations/zh-Hant.json
@@ -1,11 +1,14 @@
{
"config": {
"abort": {
- "host_port_exists": "\u6b64\u4e3b\u6a5f\u7aef\u8207\u901a\u8a0a\u57e0\u7d44\u5408\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ "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",
diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py
index f3bd2f07d63..3f77701906f 100644
--- a/homeassistant/components/cert_expiry/config_flow.py
+++ b/homeassistant/components/cert_expiry/config_flow.py
@@ -1,29 +1,23 @@
"""Config flow for the Cert Expiry platform."""
import logging
-import socket
-import ssl
import voluptuous as vol
from homeassistant import config_entries
-from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.const import CONF_HOST, CONF_PORT
-from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
-from .helper import get_cert
+from .const import DEFAULT_PORT, DOMAIN # pylint: disable=unused-import
+from .errors import (
+ ConnectionRefused,
+ ConnectionTimeout,
+ ResolveFailed,
+ ValidationFailure,
+)
+from .helper import get_cert_time_to_expiry
_LOGGER = logging.getLogger(__name__)
-@callback
-def certexpiry_entries(hass: HomeAssistant):
- """Return the host,port tuples for the domain."""
- return set(
- (entry.data[CONF_HOST], entry.data[CONF_PORT])
- for entry in hass.config_entries.async_entries(DOMAIN)
- )
-
-
class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
@@ -34,59 +28,47 @@ class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Initialize the config flow."""
self._errors = {}
- def _prt_in_configuration_exists(self, user_input) -> bool:
- """Return True if host, port combination exists in configuration."""
- host = user_input[CONF_HOST]
- port = user_input.get(CONF_PORT, DEFAULT_PORT)
- if (host, port) in certexpiry_entries(self.hass):
- return True
- return False
-
async def _test_connection(self, user_input=None):
- """Test connection to the server and try to get the certtificate."""
- host = user_input[CONF_HOST]
+ """Test connection to the server and try to get the certificate."""
try:
- await self.hass.async_add_executor_job(
- get_cert, host, user_input.get(CONF_PORT, DEFAULT_PORT)
+ await get_cert_time_to_expiry(
+ self.hass,
+ user_input[CONF_HOST],
+ user_input.get(CONF_PORT, DEFAULT_PORT),
)
return True
- except socket.gaierror:
- _LOGGER.error("Host cannot be resolved: %s", host)
+ except ResolveFailed:
self._errors[CONF_HOST] = "resolve_failed"
- except socket.timeout:
- _LOGGER.error("Timed out connecting to %s", host)
+ except ConnectionTimeout:
self._errors[CONF_HOST] = "connection_timeout"
- except ssl.CertificateError as err:
- if "doesn't match" in err.args[0]:
- _LOGGER.error("Certificate does not match host: %s", host)
- self._errors[CONF_HOST] = "wrong_host"
- else:
- _LOGGER.error("Certificate could not be validated: %s", host)
- self._errors[CONF_HOST] = "certificate_error"
- except ssl.SSLError:
- _LOGGER.error("Certificate could not be validated: %s", host)
- self._errors[CONF_HOST] = "certificate_error"
+ except ConnectionRefused:
+ self._errors[CONF_HOST] = "connection_refused"
+ except ValidationFailure:
+ return True
return False
async def async_step_user(self, user_input=None):
"""Step when user initializes a integration."""
self._errors = {}
if user_input is not None:
- # set some defaults in case we need to return to the form
- if self._prt_in_configuration_exists(user_input):
- self._errors[CONF_HOST] = "host_port_exists"
- else:
- if await self._test_connection(user_input):
- return self.async_create_entry(
- title=user_input.get(CONF_NAME, DEFAULT_NAME),
- data={
- CONF_HOST: user_input[CONF_HOST],
- CONF_PORT: user_input.get(CONF_PORT, DEFAULT_PORT),
- },
- )
+ host = user_input[CONF_HOST]
+ port = user_input.get(CONF_PORT, DEFAULT_PORT)
+ await self.async_set_unique_id(f"{host}:{port}")
+ self._abort_if_unique_id_configured()
+
+ if await self._test_connection(user_input):
+ title_port = f":{port}" if port != DEFAULT_PORT else ""
+ title = f"{host}{title_port}"
+ return self.async_create_entry(
+ title=title, data={CONF_HOST: host, CONF_PORT: port},
+ )
+ if ( # pylint: disable=no-member
+ self.context["source"] == config_entries.SOURCE_IMPORT
+ ):
+ _LOGGER.error("Config import failed for %s", user_input[CONF_HOST])
+ return self.async_abort(reason="import_failed")
else:
user_input = {}
- user_input[CONF_NAME] = DEFAULT_NAME
user_input[CONF_HOST] = ""
user_input[CONF_PORT] = DEFAULT_PORT
@@ -94,9 +76,6 @@ class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="user",
data_schema=vol.Schema(
{
- vol.Required(
- CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME)
- ): str,
vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str,
vol.Required(
CONF_PORT, default=user_input.get(CONF_PORT, DEFAULT_PORT)
@@ -111,6 +90,4 @@ class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
Only host was required in the yaml file all other fields are optional
"""
- if self._prt_in_configuration_exists(user_input):
- return self.async_abort(reason="host_port_exists")
return await self.async_step_user(user_input)
diff --git a/homeassistant/components/cert_expiry/const.py b/homeassistant/components/cert_expiry/const.py
index 4129781f2a0..00d5ac9e923 100644
--- a/homeassistant/components/cert_expiry/const.py
+++ b/homeassistant/components/cert_expiry/const.py
@@ -1,6 +1,5 @@
"""Const for Cert Expiry."""
DOMAIN = "cert_expiry"
-DEFAULT_NAME = "SSL Certificate Expiry"
DEFAULT_PORT = 443
TIMEOUT = 10.0
diff --git a/homeassistant/components/cert_expiry/errors.py b/homeassistant/components/cert_expiry/errors.py
new file mode 100644
index 00000000000..a3b73c84f2a
--- /dev/null
+++ b/homeassistant/components/cert_expiry/errors.py
@@ -0,0 +1,26 @@
+"""Errors for the cert_expiry integration."""
+from homeassistant.exceptions import HomeAssistantError
+
+
+class CertExpiryException(HomeAssistantError):
+ """Base class for cert_expiry exceptions."""
+
+
+class TemporaryFailure(CertExpiryException):
+ """Temporary failure has occurred."""
+
+
+class ValidationFailure(CertExpiryException):
+ """Certificate validation failure has occurred."""
+
+
+class ResolveFailed(TemporaryFailure):
+ """Name resolution failed."""
+
+
+class ConnectionTimeout(TemporaryFailure):
+ """Network connection timed out."""
+
+
+class ConnectionRefused(TemporaryFailure):
+ """Network connection refused."""
diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py
index cd49588ec89..bb9f2762f3a 100644
--- a/homeassistant/components/cert_expiry/helper.py
+++ b/homeassistant/components/cert_expiry/helper.py
@@ -1,12 +1,19 @@
"""Helper functions for the Cert Expiry platform."""
+from datetime import datetime
import socket
import ssl
from .const import TIMEOUT
+from .errors import (
+ ConnectionRefused,
+ ConnectionTimeout,
+ ResolveFailed,
+ ValidationFailure,
+)
def get_cert(host, port):
- """Get the ssl certificate for the host and port combination."""
+ """Get the certificate for the host and port combination."""
ctx = ssl.create_default_context()
address = (host, port)
with socket.create_connection(address, timeout=TIMEOUT) as sock:
@@ -14,3 +21,24 @@ def get_cert(host, port):
# pylint disable: https://github.com/PyCQA/pylint/issues/3166
cert = ssock.getpeercert() # pylint: disable=no-member
return cert
+
+
+async def get_cert_time_to_expiry(hass, hostname, port):
+ """Return the certificate's time to expiry in days."""
+ try:
+ cert = await hass.async_add_executor_job(get_cert, hostname, port)
+ except socket.gaierror:
+ raise ResolveFailed(f"Cannot resolve hostname: {hostname}")
+ except socket.timeout:
+ raise ConnectionTimeout(f"Connection timeout with server: {hostname}:{port}")
+ except ConnectionRefusedError:
+ raise ConnectionRefused(f"Connection refused by server: {hostname}:{port}")
+ except ssl.CertificateError as err:
+ raise ValidationFailure(err.verify_message)
+ except ssl.SSLError as err:
+ raise ValidationFailure(err.args[0])
+
+ ts_seconds = ssl.cert_time_to_seconds(cert["notAfter"])
+ timestamp = datetime.fromtimestamp(ts_seconds)
+ expiry = timestamp - datetime.today()
+ return expiry.days
diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py
index 3a76575dfdd..39ec2c35ac7 100644
--- a/homeassistant/components/cert_expiry/sensor.py
+++ b/homeassistant/components/cert_expiry/sensor.py
@@ -1,8 +1,6 @@
"""Counter for the days until an HTTPS (TLS) certificate will expire."""
-from datetime import datetime, timedelta
+from datetime import timedelta
import logging
-import socket
-import ssl
import voluptuous as vol
@@ -13,49 +11,74 @@ from homeassistant.const import (
CONF_NAME,
CONF_PORT,
EVENT_HOMEASSISTANT_START,
+ TIME_DAYS,
)
from homeassistant.core import callback
+from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_call_later
-from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
-from .helper import get_cert
+from .const import DEFAULT_PORT, DOMAIN
+from .errors import TemporaryFailure, ValidationFailure
+from .helper import get_cert_time_to_expiry
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(hours=12)
-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,
- }
+PLATFORM_SCHEMA = vol.All(
+ cv.deprecated(CONF_NAME, invalidation_version="0.109"),
+ PLATFORM_SCHEMA.extend(
+ {
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ }
+ ),
)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up certificate expiry sensor."""
+ @callback
+ def schedule_import(_):
+ """Schedule delayed import after HA is fully started."""
+ async_call_later(hass, 10, do_import)
+
@callback
def do_import(_):
- """Process YAML import after HA is fully started."""
+ """Process YAML import."""
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=dict(config)
)
)
- # Delay to avoid validation during setup in case we're checking our own cert.
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, do_import)
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, schedule_import)
async def async_setup_entry(hass, entry, async_add_entities):
"""Add cert-expiry entry."""
+ days = 0
+ error = None
+ hostname = entry.data[CONF_HOST]
+ port = entry.data[CONF_PORT]
+
+ if entry.unique_id is None:
+ hass.config_entries.async_update_entry(entry, unique_id=f"{hostname}:{port}")
+
+ try:
+ days = await get_cert_time_to_expiry(hass, hostname, port)
+ except TemporaryFailure as err:
+ _LOGGER.error(err)
+ raise PlatformNotReady
+ except ValidationFailure as err:
+ error = err
+
async_add_entities(
- [SSLCertificate(entry.title, entry.data[CONF_HOST], entry.data[CONF_PORT])],
- False,
- # Don't update in case we're checking our own cert.
+ [SSLCertificate(hostname, port, days, error)], False,
)
return True
@@ -63,14 +86,18 @@ async def async_setup_entry(hass, entry, async_add_entities):
class SSLCertificate(Entity):
"""Implementation of the certificate expiry sensor."""
- def __init__(self, sensor_name, server_name, server_port):
+ def __init__(self, server_name, server_port, days, error):
"""Initialize the sensor."""
self.server_name = server_name
self.server_port = server_port
- self._name = sensor_name
- self._state = None
- self._available = False
+ display_port = f":{server_port}" if server_port != DEFAULT_PORT else ""
+ self._name = f"Cert Expiry ({self.server_name}{display_port})"
+ self._available = True
+ self._error = error
+ self._state = days
self._valid = False
+ if error is None:
+ self._valid = True
@property
def name(self):
@@ -85,7 +112,7 @@ class SSLCertificate(Entity):
@property
def unit_of_measurement(self):
"""Return the unit this state is expressed in."""
- return "days"
+ return TIME_DAYS
@property
def state(self):
@@ -102,50 +129,38 @@ class SSLCertificate(Entity):
"""Return the availability of the sensor."""
return self._available
- async def async_added_to_hass(self):
- """Once the entity is added we should update to get the initial data loaded."""
-
- @callback
- def do_update(_):
- """Run the update method when the start event was fired."""
- self.async_schedule_update_ha_state(True)
-
- if self.hass.is_running:
- self.async_schedule_update_ha_state(True)
- else:
- # Delay until HA is fully started in case we're checking our own cert.
- self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, do_update)
-
- def update(self):
+ async def async_update(self):
"""Fetch the certificate information."""
try:
- cert = get_cert(self.server_name, self.server_port)
- except socket.gaierror:
- _LOGGER.error("Cannot resolve hostname: %s", self.server_name)
+ days_to_expiry = await get_cert_time_to_expiry(
+ self.hass, self.server_name, self.server_port
+ )
+ except TemporaryFailure as err:
+ _LOGGER.error(err.args[0])
self._available = False
- self._valid = False
return
- except socket.timeout:
- _LOGGER.error("Connection timeout with server: %s", self.server_name)
- self._available = False
- self._valid = False
- return
- except (ssl.CertificateError, ssl.SSLError):
+ except ValidationFailure as err:
+ _LOGGER.error(
+ "Certificate validation error: %s [%s]", self.server_name, err
+ )
self._available = True
+ self._error = err
self._state = 0
self._valid = False
return
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception(
+ "Unknown error checking %s:%s", self.server_name, self.server_port
+ )
+ self._available = False
+ return
- ts_seconds = ssl.cert_time_to_seconds(cert["notAfter"])
- timestamp = datetime.fromtimestamp(ts_seconds)
- expiry = timestamp - datetime.today()
self._available = True
- self._state = expiry.days
+ self._error = None
+ self._state = days_to_expiry
self._valid = True
@property
def device_state_attributes(self):
"""Return additional sensor state attributes."""
- attr = {"is_valid": self._valid}
-
- return attr
+ return {"is_valid": self._valid, "error": str(self._error)}
diff --git a/homeassistant/components/cert_expiry/strings.json b/homeassistant/components/cert_expiry/strings.json
index e5e670d214f..4d4982a19af 100644
--- a/homeassistant/components/cert_expiry/strings.json
+++ b/homeassistant/components/cert_expiry/strings.json
@@ -12,14 +12,13 @@
}
},
"error": {
- "host_port_exists": "This host and port combination is already configured",
"resolve_failed": "This host can not be resolved",
"connection_timeout": "Timeout when connecting to this host",
- "certificate_error": "Certificate could not be validated",
- "wrong_host": "Certificate does not match hostname"
+ "connection_refused": "Connection refused when connecting to host"
},
"abort": {
- "host_port_exists": "This host and port combination is already configured"
+ "already_configured": "This host and port combination is already configured",
+ "import_failed": "Import from config failed"
}
}
}
diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py
index 4c5dcb0ee04..187f50bc984 100644
--- a/homeassistant/components/climate/device_trigger.py
+++ b/homeassistant/components/climate/device_trigger.py
@@ -18,6 +18,7 @@ from homeassistant.const import (
CONF_FOR,
CONF_PLATFORM,
CONF_TYPE,
+ UNIT_PERCENTAGE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_registry
@@ -177,7 +178,7 @@ async def async_get_trigger_capabilities(hass: HomeAssistant, config):
if trigger_type == "current_temperature_changed":
unit_of_measurement = hass.config.units.temperature_unit
else:
- unit_of_measurement = "%"
+ unit_of_measurement = UNIT_PERCENTAGE
return {
"extra_fields": vol.Schema(
diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py
index 9afaad422ba..c532a2063a7 100644
--- a/homeassistant/components/cloud/http_api.py
+++ b/homeassistant/components/cloud/http_api.py
@@ -236,9 +236,7 @@ class CloudRegisterView(HomeAssistantView):
cloud = hass.data[DOMAIN]
with async_timeout.timeout(REQUEST_TIMEOUT):
- await hass.async_add_job(
- cloud.auth.register, data["email"], data["password"]
- )
+ await cloud.auth.async_register(data["email"], data["password"])
return self.json_message("ok")
@@ -257,7 +255,7 @@ class CloudResendConfirmView(HomeAssistantView):
cloud = hass.data[DOMAIN]
with async_timeout.timeout(REQUEST_TIMEOUT):
- await hass.async_add_job(cloud.auth.resend_email_confirm, data["email"])
+ await cloud.auth.async_resend_email_confirm(data["email"])
return self.json_message("ok")
@@ -276,7 +274,7 @@ class CloudForgotPasswordView(HomeAssistantView):
cloud = hass.data[DOMAIN]
with async_timeout.timeout(REQUEST_TIMEOUT):
- await hass.async_add_job(cloud.auth.forgot_password, data["email"])
+ await cloud.auth.async_forgot_password(data["email"])
return self.json_message("ok")
@@ -336,7 +334,7 @@ async def websocket_subscription(hass, connection, msg):
# In that case, let's refresh and reconnect
if data.get("provider") and not cloud.is_connected:
_LOGGER.debug("Found disconnected account with valid subscriotion, connecting")
- await hass.async_add_executor_job(cloud.auth.renew_access_token)
+ await cloud.auth.async_renew_access_token()
# Cancel reconnect in progress
if cloud.iot.state != STATE_DISCONNECTED:
diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json
index 34ef7a6dfa5..cfbb221c164 100644
--- a/homeassistant/components/cloud/manifest.json
+++ b/homeassistant/components/cloud/manifest.json
@@ -2,8 +2,8 @@
"domain": "cloud",
"name": "Home Assistant Cloud",
"documentation": "https://www.home-assistant.io/integrations/cloud",
- "requirements": ["hass-nabucasa==0.31"],
- "dependencies": ["http", "webhook"],
- "after_dependencies": ["alexa", "google_assistant"],
+ "requirements": ["hass-nabucasa==0.32.2"],
+ "dependencies": ["http", "webhook", "alexa"],
+ "after_dependencies": ["google_assistant"],
"codeowners": ["@home-assistant/cloud"]
}
diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py
index 7160d140b3f..31a06c94120 100644
--- a/homeassistant/components/co2signal/sensor.py
+++ b/homeassistant/components/co2signal/sensor.py
@@ -65,9 +65,7 @@ class CO2Sensor(Entity):
if country_code is not None:
device_name = country_code
else:
- device_name = "{lat}/{lon}".format(
- lat=round(self._latitude, 2), lon=round(self._longitude, 2)
- )
+ device_name = f"{round(self._latitude, 2)}/{round(self._longitude, 2)}"
self._friendly_name = f"CO2 intensity - {device_name}"
diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py
index 4a3e85d5e43..a13dfef11da 100644
--- a/homeassistant/components/coinbase/sensor.py
+++ b/homeassistant/components/coinbase/sensor.py
@@ -75,9 +75,7 @@ class AccountSensor(Entity):
"""Return the state attributes of the sensor."""
return {
ATTR_ATTRIBUTION: ATTRIBUTION,
- ATTR_NATIVE_BALANCE: "{} {}".format(
- self._native_balance, self._native_currency
- ),
+ ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._native_currency}",
}
def update(self):
diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py
index 3e3507ea48d..5c8c0d6a75c 100644
--- a/homeassistant/components/comfoconnect/sensor.py
+++ b/homeassistant/components/comfoconnect/sensor.py
@@ -31,6 +31,9 @@ from homeassistant.const import (
DEVICE_CLASS_TEMPERATURE,
POWER_WATT,
TEMP_CELSIUS,
+ TIME_DAYS,
+ TIME_HOURS,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -76,7 +79,7 @@ SENSOR_TYPES = {
ATTR_CURRENT_HUMIDITY: {
ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
ATTR_LABEL: "Inside Humidity",
- ATTR_UNIT: "%",
+ ATTR_UNIT: UNIT_PERCENTAGE,
ATTR_ICON: "mdi:water-percent",
ATTR_ID: SENSOR_HUMIDITY_EXTRACT,
},
@@ -91,7 +94,7 @@ SENSOR_TYPES = {
ATTR_OUTSIDE_HUMIDITY: {
ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
ATTR_LABEL: "Outside Humidity",
- ATTR_UNIT: "%",
+ ATTR_UNIT: UNIT_PERCENTAGE,
ATTR_ICON: "mdi:water-percent",
ATTR_ID: SENSOR_HUMIDITY_OUTDOOR,
},
@@ -106,7 +109,7 @@ SENSOR_TYPES = {
ATTR_SUPPLY_HUMIDITY: {
ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
ATTR_LABEL: "Supply Humidity",
- ATTR_UNIT: "%",
+ ATTR_UNIT: UNIT_PERCENTAGE,
ATTR_ICON: "mdi:water-percent",
ATTR_ID: SENSOR_HUMIDITY_SUPPLY,
},
@@ -120,7 +123,7 @@ SENSOR_TYPES = {
ATTR_SUPPLY_FAN_DUTY: {
ATTR_DEVICE_CLASS: None,
ATTR_LABEL: "Supply Fan Duty",
- ATTR_UNIT: "%",
+ ATTR_UNIT: UNIT_PERCENTAGE,
ATTR_ICON: "mdi:fan",
ATTR_ID: SENSOR_FAN_SUPPLY_DUTY,
},
@@ -134,7 +137,7 @@ SENSOR_TYPES = {
ATTR_EXHAUST_FAN_DUTY: {
ATTR_DEVICE_CLASS: None,
ATTR_LABEL: "Exhaust Fan Duty",
- ATTR_UNIT: "%",
+ ATTR_UNIT: UNIT_PERCENTAGE,
ATTR_ICON: "mdi:fan",
ATTR_ID: SENSOR_FAN_EXHAUST_DUTY,
},
@@ -149,35 +152,35 @@ SENSOR_TYPES = {
ATTR_EXHAUST_HUMIDITY: {
ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
ATTR_LABEL: "Exhaust Humidity",
- ATTR_UNIT: "%",
+ ATTR_UNIT: UNIT_PERCENTAGE,
ATTR_ICON: "mdi:water-percent",
ATTR_ID: SENSOR_HUMIDITY_EXHAUST,
},
ATTR_AIR_FLOW_SUPPLY: {
ATTR_DEVICE_CLASS: None,
ATTR_LABEL: "Supply airflow",
- ATTR_UNIT: "m³/h",
+ ATTR_UNIT: f"m³/{TIME_HOURS}",
ATTR_ICON: "mdi:fan",
ATTR_ID: SENSOR_FAN_SUPPLY_FLOW,
},
ATTR_AIR_FLOW_EXHAUST: {
ATTR_DEVICE_CLASS: None,
ATTR_LABEL: "Exhaust airflow",
- ATTR_UNIT: "m³/h",
+ ATTR_UNIT: f"m³/{TIME_HOURS}",
ATTR_ICON: "mdi:fan",
ATTR_ID: SENSOR_FAN_EXHAUST_FLOW,
},
ATTR_BYPASS_STATE: {
ATTR_DEVICE_CLASS: None,
ATTR_LABEL: "Bypass State",
- ATTR_UNIT: "%",
+ ATTR_UNIT: UNIT_PERCENTAGE,
ATTR_ICON: "mdi:camera-iris",
ATTR_ID: SENSOR_BYPASS_STATE,
},
ATTR_DAYS_TO_REPLACE_FILTER: {
ATTR_DEVICE_CLASS: None,
ATTR_LABEL: "Days to replace filter",
- ATTR_UNIT: "days",
+ ATTR_UNIT: TIME_DAYS,
ATTR_ICON: "mdi:calendar",
ATTR_ID: SENSOR_DAYS_TO_REPLACE_FILTER,
},
@@ -194,7 +197,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_RESOURCES, default=[]): vol.All(
cv.ensure_list, [vol.In(SENSOR_TYPES)]
- ),
+ )
}
)
@@ -229,7 +232,7 @@ class ComfoConnectSensor(Entity):
async def async_added_to_hass(self):
"""Register for sensor updates."""
_LOGGER.debug(
- "Registering for sensor %s (%d)", self._sensor_type, self._sensor_id,
+ "Registering for sensor %s (%d)", self._sensor_type, self._sensor_id
)
async_dispatcher_connect(
self.hass,
diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py
index a7993017116..f024f146a60 100644
--- a/homeassistant/components/config/entity_registry.py
+++ b/homeassistant/components/config/entity_registry.py
@@ -57,7 +57,9 @@ async def websocket_get_entity(hass, connection, msg):
)
return
- connection.send_message(websocket_api.result_message(msg["id"], _entry_dict(entry)))
+ connection.send_message(
+ websocket_api.result_message(msg["id"], _entry_ext_dict(entry))
+ )
@require_admin
@@ -112,7 +114,7 @@ async def websocket_update_entity(hass, connection, msg):
)
else:
connection.send_message(
- websocket_api.result_message(msg["id"], _entry_dict(entry))
+ websocket_api.result_message(msg["id"], _entry_ext_dict(entry))
)
@@ -152,6 +154,15 @@ def _entry_dict(entry):
"name": entry.name,
"icon": entry.icon,
"platform": entry.platform,
- "original_name": entry.original_name,
- "original_icon": entry.original_icon,
}
+
+
+@callback
+def _entry_ext_dict(entry):
+ """Convert entry to API format."""
+ data = _entry_dict(entry)
+ data["original_name"] = entry.original_name
+ data["original_icon"] = entry.original_icon
+ data["unique_id"] = entry.unique_id
+ data["capabilities"] = entry.capabilities
+ return data
diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py
index 4d79b13355c..e1e6181d8ca 100644
--- a/homeassistant/components/configurator/__init__.py
+++ b/homeassistant/components/configurator/__init__.py
@@ -237,7 +237,7 @@ class Configurator:
def _generate_unique_id(self):
"""Generate a unique configurator ID."""
self._cur_id += 1
- return "{}-{}".format(id(self), self._cur_id)
+ return f"{id(self)}-{self._cur_id}"
def _validate_request_id(self, request_id):
"""Validate that the request belongs to this instance."""
diff --git a/homeassistant/components/coronavirus/.translations/en.json b/homeassistant/components/coronavirus/.translations/en.json
index ad7a3cf2cdf..b19e42cdf27 100644
--- a/homeassistant/components/coronavirus/.translations/en.json
+++ b/homeassistant/components/coronavirus/.translations/en.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "already_configured": "This country is already configured."
+ },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/coronavirus/.translations/es.json b/homeassistant/components/coronavirus/.translations/es.json
new file mode 100644
index 00000000000..edc31f48761
--- /dev/null
+++ b/homeassistant/components/coronavirus/.translations/es.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Este pa\u00eds ya est\u00e1 configurado."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "country": "Pa\u00eds"
+ },
+ "title": "Elige un pa\u00eds para monitorizar"
+ }
+ },
+ "title": "Coronavirus"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/coronavirus/.translations/lb.json b/homeassistant/components/coronavirus/.translations/lb.json
new file mode 100644
index 00000000000..dbd56e461bb
--- /dev/null
+++ b/homeassistant/components/coronavirus/.translations/lb.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "D\u00ebst Land ass scho konfigur\u00e9iert"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "country": "Land"
+ },
+ "title": "Wiel ee Land aus fir z'iwwerwaachen"
+ }
+ },
+ "title": "Coronavirus"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/coronavirus/.translations/no.json b/homeassistant/components/coronavirus/.translations/no.json
new file mode 100644
index 00000000000..ef5d75ac2a9
--- /dev/null
+++ b/homeassistant/components/coronavirus/.translations/no.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Dette landet er allerede konfigurert."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "country": "Land"
+ },
+ "title": "Velg et land du vil overv\u00e5ke"
+ }
+ },
+ "title": "Coronavirus"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/coronavirus/.translations/ru.json b/homeassistant/components/coronavirus/.translations/ru.json
new file mode 100644
index 00000000000..b8e5a069e4a
--- /dev/null
+++ b/homeassistant/components/coronavirus/.translations/ru.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "country": "\u0421\u0442\u0440\u0430\u043d\u0430"
+ },
+ "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u0442\u0440\u0430\u043d\u0443 \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430"
+ }
+ },
+ "title": "\u041a\u043e\u0440\u043e\u043d\u0430\u0432\u0438\u0440\u0443\u0441"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/coronavirus/.translations/zh-Hans.json b/homeassistant/components/coronavirus/.translations/zh-Hans.json
new file mode 100644
index 00000000000..f122e794424
--- /dev/null
+++ b/homeassistant/components/coronavirus/.translations/zh-Hans.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u6b64\u56fd\u5bb6/\u5730\u533a\u5df2\u914d\u7f6e\u5b8c\u6210\u3002"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "country": "\u56fd\u5bb6/\u5730\u533a"
+ },
+ "title": "\u8bf7\u9009\u62e9\u8981\u76d1\u63a7\u7684\u56fd\u5bb6/\u5730\u533a"
+ }
+ },
+ "title": "\u65b0\u578b\u51a0\u72b6\u75c5\u6bd2"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/coronavirus/.translations/zh-Hant.json b/homeassistant/components/coronavirus/.translations/zh-Hant.json
new file mode 100644
index 00000000000..7286694fd9b
--- /dev/null
+++ b/homeassistant/components/coronavirus/.translations/zh-Hant.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u6b64\u570b\u5bb6\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "country": "\u570b\u5bb6"
+ },
+ "title": "\u9078\u64c7\u6240\u8981\u76e3\u8996\u7684\u570b\u5bb6"
+ }
+ },
+ "title": "\u65b0\u51a0\u72c0\u75c5\u6bd2"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py
index 04976a1e4c5..fa8efebe154 100644
--- a/homeassistant/components/coronavirus/__init__.py
+++ b/homeassistant/components/coronavirus/__init__.py
@@ -3,7 +3,6 @@ import asyncio
from datetime import timedelta
import logging
-import aiohttp
import async_timeout
import coronavirus
@@ -73,16 +72,13 @@ async def get_coordinator(hass):
return hass.data[DOMAIN]
async def async_get_cases():
- try:
- with async_timeout.timeout(10):
- return {
- case.country: case
- for case in await coronavirus.get_cases(
- aiohttp_client.async_get_clientsession(hass)
- )
- }
- except (asyncio.TimeoutError, aiohttp.ClientError):
- raise update_coordinator.UpdateFailed
+ with async_timeout.timeout(10):
+ return {
+ case.country: case
+ for case in await coronavirus.get_cases(
+ aiohttp_client.async_get_clientsession(hass)
+ )
+ }
hass.data[DOMAIN] = update_coordinator.DataUpdateCoordinator(
hass,
diff --git a/homeassistant/components/coronavirus/sensor.py b/homeassistant/components/coronavirus/sensor.py
index 20f18896431..2887427ec6b 100644
--- a/homeassistant/components/coronavirus/sensor.py
+++ b/homeassistant/components/coronavirus/sensor.py
@@ -5,6 +5,13 @@ from homeassistant.helpers.entity import Entity
from . import get_coordinator
from .const import ATTRIBUTION, OPTION_WORLDWIDE
+SENSORS = {
+ "confirmed": "mdi:emoticon-neutral-outline",
+ "current": "mdi:emoticon-sad-outline",
+ "recovered": "mdi:emoticon-happy-outline",
+ "deaths": "mdi:emoticon-cry-outline",
+}
+
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Defer sensor setup to the shared sensor module."""
@@ -12,7 +19,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async_add_entities(
CoronavirusSensor(coordinator, config_entry.data["country"], info_type)
- for info_type in ("confirmed", "recovered", "deaths", "current")
+ for info_type in SENSORS
)
@@ -50,6 +57,11 @@ class CoronavirusSensor(Entity):
return getattr(self.coordinator.data[self.country], self.info_type)
+ @property
+ def icon(self):
+ """Return the icon."""
+ return SENSORS[self.info_type]
+
@property
def unit_of_measurement(self):
"""Return unit of measurement."""
diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py
index 5580518a9a3..ad5e4000116 100644
--- a/homeassistant/components/counter/__init__.py
+++ b/homeassistant/components/counter/__init__.py
@@ -1,12 +1,24 @@
"""Component to count within automations."""
import logging
+from typing import Dict, Optional
import voluptuous as vol
-from homeassistant.const import CONF_ICON, CONF_MAXIMUM, CONF_MINIMUM, CONF_NAME
+from homeassistant.const import (
+ ATTR_EDITABLE,
+ CONF_ICON,
+ CONF_ID,
+ CONF_MAXIMUM,
+ CONF_MINIMUM,
+ CONF_NAME,
+)
+from homeassistant.core import callback
+from homeassistant.helpers import collection
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.helpers.storage import Store
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
_LOGGER = logging.getLogger(__name__)
@@ -31,6 +43,29 @@ SERVICE_INCREMENT = "increment"
SERVICE_RESET = "reset"
SERVICE_CONFIGURE = "configure"
+STORAGE_KEY = DOMAIN
+STORAGE_VERSION = 1
+
+CREATE_FIELDS = {
+ vol.Optional(CONF_ICON): cv.icon,
+ vol.Optional(CONF_INITIAL, default=DEFAULT_INITIAL): cv.positive_int,
+ vol.Required(CONF_NAME): vol.All(cv.string, vol.Length(min=1)),
+ vol.Optional(CONF_MAXIMUM, default=None): vol.Any(None, vol.Coerce(int)),
+ vol.Optional(CONF_MINIMUM, default=None): vol.Any(None, vol.Coerce(int)),
+ vol.Optional(CONF_RESTORE, default=True): cv.boolean,
+ vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.positive_int,
+}
+
+UPDATE_FIELDS = {
+ vol.Optional(CONF_ICON): cv.icon,
+ vol.Optional(CONF_INITIAL): cv.positive_int,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_MAXIMUM): vol.Any(None, vol.Coerce(int)),
+ vol.Optional(CONF_MINIMUM): vol.Any(None, vol.Coerce(int)),
+ vol.Optional(CONF_RESTORE): cv.boolean,
+ vol.Optional(CONF_STEP): cv.positive_int,
+}
+
def _none_to_empty_dict(value):
if value is None:
@@ -65,30 +100,38 @@ CONFIG_SCHEMA = vol.Schema(
)
-async def async_setup(hass, config):
+async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
"""Set up the counters."""
component = EntityComponent(_LOGGER, DOMAIN, hass)
+ id_manager = collection.IDManager()
- entities = []
+ yaml_collection = collection.YamlCollection(
+ logging.getLogger(f"{__name__}.yaml_collection"), id_manager
+ )
+ collection.attach_entity_component_collection(
+ component, yaml_collection, Counter.from_yaml
+ )
- for object_id, cfg in config[DOMAIN].items():
- if not cfg:
- cfg = {}
+ storage_collection = CounterStorageCollection(
+ Store(hass, STORAGE_VERSION, STORAGE_KEY),
+ logging.getLogger(f"{__name__}.storage_collection"),
+ id_manager,
+ )
+ collection.attach_entity_component_collection(
+ component, storage_collection, Counter
+ )
- name = cfg.get(CONF_NAME)
- initial = cfg[CONF_INITIAL]
- restore = cfg[CONF_RESTORE]
- step = cfg[CONF_STEP]
- icon = cfg.get(CONF_ICON)
- minimum = cfg[CONF_MINIMUM]
- maximum = cfg[CONF_MAXIMUM]
+ await yaml_collection.async_load(
+ [{CONF_ID: id_, **(conf or {})} for id_, conf in config.get(DOMAIN, {}).items()]
+ )
+ await storage_collection.async_load()
- entities.append(
- Counter(object_id, name, initial, minimum, maximum, restore, step, icon)
- )
+ collection.StorageCollectionWebsocket(
+ storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS
+ ).async_setup(hass)
- if not entities:
- return False
+ collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, yaml_collection)
+ collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, storage_collection)
component.async_register_entity_service(SERVICE_INCREMENT, {}, "async_increment")
component.async_register_entity_service(SERVICE_DECREMENT, {}, "async_decrement")
@@ -105,104 +148,137 @@ async def async_setup(hass, config):
"async_configure",
)
- await component.async_add_entities(entities)
return True
+class CounterStorageCollection(collection.StorageCollection):
+ """Input storage based collection."""
+
+ CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
+ UPDATE_SCHEMA = vol.Schema(UPDATE_FIELDS)
+
+ async def _process_create_data(self, data: Dict) -> Dict:
+ """Validate the config is valid."""
+ return self.CREATE_SCHEMA(data)
+
+ @callback
+ def _get_suggested_id(self, info: Dict) -> str:
+ """Suggest an ID based on the config."""
+ return info[CONF_NAME]
+
+ async def _update_data(self, data: dict, update_data: Dict) -> Dict:
+ """Return a new updated data object."""
+ update_data = self.UPDATE_SCHEMA(update_data)
+ return {**data, **update_data}
+
+
class Counter(RestoreEntity):
"""Representation of a counter."""
- def __init__(self, object_id, name, initial, minimum, maximum, restore, step, icon):
+ def __init__(self, config: Dict):
"""Initialize a counter."""
- self.entity_id = ENTITY_ID_FORMAT.format(object_id)
- self._name = name
- self._restore = restore
- self._step = step
- self._state = self._initial = initial
- self._min = minimum
- self._max = maximum
- self._icon = icon
+ self._config: Dict = config
+ self._state: Optional[int] = config[CONF_INITIAL]
+ self.editable: bool = True
+
+ @classmethod
+ def from_yaml(cls, config: Dict) -> "Counter":
+ """Create counter instance from yaml config."""
+ counter = cls(config)
+ counter.editable = False
+ counter.entity_id = ENTITY_ID_FORMAT.format(config[CONF_ID])
+ return counter
@property
- def should_poll(self):
+ def should_poll(self) -> bool:
"""If entity should be polled."""
return False
@property
- def name(self):
+ def name(self) -> Optional[str]:
"""Return name of the counter."""
- return self._name
+ return self._config.get(CONF_NAME)
@property
- def icon(self):
+ def icon(self) -> Optional[str]:
"""Return the icon to be used for this entity."""
- return self._icon
+ return self._config.get(CONF_ICON)
@property
- def state(self):
+ def state(self) -> Optional[int]:
"""Return the current value of the counter."""
return self._state
@property
- def state_attributes(self):
+ def state_attributes(self) -> Dict:
"""Return the state attributes."""
- ret = {ATTR_INITIAL: self._initial, ATTR_STEP: self._step}
- if self._min is not None:
- ret[CONF_MINIMUM] = self._min
- if self._max is not None:
- ret[CONF_MAXIMUM] = self._max
+ ret = {
+ ATTR_EDITABLE: self.editable,
+ ATTR_INITIAL: self._config[CONF_INITIAL],
+ ATTR_STEP: self._config[CONF_STEP],
+ }
+ if self._config[CONF_MINIMUM] is not None:
+ ret[CONF_MINIMUM] = self._config[CONF_MINIMUM]
+ if self._config[CONF_MAXIMUM] is not None:
+ ret[CONF_MAXIMUM] = self._config[CONF_MAXIMUM]
return ret
- def compute_next_state(self, state):
+ @property
+ def unique_id(self) -> Optional[str]:
+ """Return unique id of the entity."""
+ return self._config[CONF_ID]
+
+ def compute_next_state(self, state) -> int:
"""Keep the state within the range of min/max values."""
- if self._min is not None:
- state = max(self._min, state)
- if self._max is not None:
- state = min(self._max, state)
+ if self._config[CONF_MINIMUM] is not None:
+ state = max(self._config[CONF_MINIMUM], state)
+ if self._config[CONF_MAXIMUM] is not None:
+ state = min(self._config[CONF_MAXIMUM], state)
return state
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Call when entity about to be added to Home Assistant."""
await super().async_added_to_hass()
# __init__ will set self._state to self._initial, only override
# if needed.
- if self._restore:
+ if self._config[CONF_RESTORE]:
state = await self.async_get_last_state()
if state is not None:
self._state = self.compute_next_state(int(state.state))
- self._initial = state.attributes.get(ATTR_INITIAL)
- self._max = state.attributes.get(ATTR_MAXIMUM)
- self._min = state.attributes.get(ATTR_MINIMUM)
- self._step = state.attributes.get(ATTR_STEP)
+ self._config[CONF_INITIAL] = state.attributes.get(ATTR_INITIAL)
+ self._config[CONF_MAXIMUM] = state.attributes.get(ATTR_MAXIMUM)
+ self._config[CONF_MINIMUM] = state.attributes.get(ATTR_MINIMUM)
+ self._config[CONF_STEP] = state.attributes.get(ATTR_STEP)
- async def async_decrement(self):
+ @callback
+ def async_decrement(self) -> None:
"""Decrement the counter."""
- self._state = self.compute_next_state(self._state - self._step)
- await self.async_update_ha_state()
+ self._state = self.compute_next_state(self._state - self._config[CONF_STEP])
+ self.async_write_ha_state()
- async def async_increment(self):
+ @callback
+ def async_increment(self) -> None:
"""Increment a counter."""
- self._state = self.compute_next_state(self._state + self._step)
- await self.async_update_ha_state()
+ self._state = self.compute_next_state(self._state + self._config[CONF_STEP])
+ self.async_write_ha_state()
- async def async_reset(self):
+ @callback
+ def async_reset(self) -> None:
"""Reset a counter."""
- self._state = self.compute_next_state(self._initial)
- await self.async_update_ha_state()
+ self._state = self.compute_next_state(self._config[CONF_INITIAL])
+ self.async_write_ha_state()
- async def async_configure(self, **kwargs):
+ @callback
+ def async_configure(self, **kwargs) -> None:
"""Change the counter's settings with a service."""
- if CONF_MINIMUM in kwargs:
- self._min = kwargs[CONF_MINIMUM]
- if CONF_MAXIMUM in kwargs:
- self._max = kwargs[CONF_MAXIMUM]
- if CONF_STEP in kwargs:
- self._step = kwargs[CONF_STEP]
- if CONF_INITIAL in kwargs:
- self._initial = kwargs[CONF_INITIAL]
- if VALUE in kwargs:
- self._state = kwargs[VALUE]
+ new_state = kwargs.pop(VALUE, self._state)
+ self._config = {**self._config, **kwargs}
+ self._state = self.compute_next_state(new_state)
+ self.async_write_ha_state()
+ async def async_update_config(self, config: Dict) -> None:
+ """Change the counter's settings WS CRUD."""
+ self._config = config
self._state = self.compute_next_state(self._state)
- await self.async_update_ha_state()
+ self.async_write_ha_state()
diff --git a/homeassistant/components/cover/.translations/ca.json b/homeassistant/components/cover/.translations/ca.json
index b2c2371db5c..bbff63722d2 100644
--- a/homeassistant/components/cover/.translations/ca.json
+++ b/homeassistant/components/cover/.translations/ca.json
@@ -1,5 +1,11 @@
{
"device_automation": {
+ "action_type": {
+ "close": "Tanca {entity_name}",
+ "open": "Obre {entity_name}",
+ "set_position": "Estableix la posici\u00f3 de {entity_name}",
+ "set_tilt_position": "Estableix la inclinaci\u00f3 de {entity_name}"
+ },
"condition_type": {
"is_closed": "{entity_name} est\u00e0 tancat/da",
"is_closing": "{entity_name} est\u00e0 tancant-se",
diff --git a/homeassistant/components/cover/.translations/en.json b/homeassistant/components/cover/.translations/en.json
index 27710f79436..e529d6e77d7 100644
--- a/homeassistant/components/cover/.translations/en.json
+++ b/homeassistant/components/cover/.translations/en.json
@@ -1,5 +1,13 @@
{
"device_automation": {
+ "action_type": {
+ "close": "Close {entity_name}",
+ "close_tilt": "Close {entity_name} tilt",
+ "open": "Open {entity_name}",
+ "open_tilt": "Open {entity_name} tilt",
+ "set_position": "Set {entity_name} position",
+ "set_tilt_position": "Set {entity_name} tilt position"
+ },
"condition_type": {
"is_closed": "{entity_name} is closed",
"is_closing": "{entity_name} is closing",
diff --git a/homeassistant/components/cover/.translations/es.json b/homeassistant/components/cover/.translations/es.json
index 490583b54c4..04efe4964e8 100644
--- a/homeassistant/components/cover/.translations/es.json
+++ b/homeassistant/components/cover/.translations/es.json
@@ -1,5 +1,13 @@
{
"device_automation": {
+ "action_type": {
+ "close": "Cerrar {entity_name}",
+ "close_tilt": "Cerrar inclinaci\u00f3n de {entity_name}",
+ "open": "Abrir {entity_name}",
+ "open_tilt": "Abrir inclinaci\u00f3n de {entity_name}",
+ "set_position": "Ajustar la posici\u00f3n de {entity_name}",
+ "set_tilt_position": "Ajustar la posici\u00f3n de inclinaci\u00f3n de {entity_name}"
+ },
"condition_type": {
"is_closed": "{entity_name} est\u00e1 cerrado",
"is_closing": "{entity_name} se est\u00e1 cerrando",
diff --git a/homeassistant/components/cover/.translations/it.json b/homeassistant/components/cover/.translations/it.json
index bc9413d4a00..1e2e85821a9 100644
--- a/homeassistant/components/cover/.translations/it.json
+++ b/homeassistant/components/cover/.translations/it.json
@@ -1,5 +1,13 @@
{
"device_automation": {
+ "action_type": {
+ "close": "Chiudi {entity_name}",
+ "close_tilt": "Chiudi l'inclinazione di {entity_name}",
+ "open": "Apri {entity_name}",
+ "open_tilt": "Apri l'inclinazione di {entity_name}",
+ "set_position": "Imposta la posizione di {entity_name}",
+ "set_tilt_position": "Imposta la posizione di inclinazione di {entity_name}"
+ },
"condition_type": {
"is_closed": "{entity_name} \u00e8 chiuso",
"is_closing": "{entity_name} si sta chiudendo",
diff --git a/homeassistant/components/cover/.translations/lb.json b/homeassistant/components/cover/.translations/lb.json
index b2645f3e001..41c29adf91d 100644
--- a/homeassistant/components/cover/.translations/lb.json
+++ b/homeassistant/components/cover/.translations/lb.json
@@ -1,5 +1,10 @@
{
"device_automation": {
+ "action_type": {
+ "open": "{entity_name} opmaachen",
+ "set_position": "{entity_name} positioun programm\u00e9ieren",
+ "set_tilt_position": "{entity_name} kipp positioun programm\u00e9ieren"
+ },
"condition_type": {
"is_closed": "{entity_name} ass zou",
"is_closing": "{entity_name} g\u00ebtt zougemaach",
diff --git a/homeassistant/components/cover/.translations/no.json b/homeassistant/components/cover/.translations/no.json
index cc045e43624..369d6b30cb8 100644
--- a/homeassistant/components/cover/.translations/no.json
+++ b/homeassistant/components/cover/.translations/no.json
@@ -1,5 +1,13 @@
{
"device_automation": {
+ "action_type": {
+ "close": "Lukk {entity_name}",
+ "close_tilt": "Lukk {entity_name} tilt",
+ "open": "\u00c5pne {entity_name}",
+ "open_tilt": "\u00c5pne {entity_name} tilt",
+ "set_position": "Angi {entity_name} posisjon",
+ "set_tilt_position": "Angi {entity_name} tilt posisjon"
+ },
"condition_type": {
"is_closed": "{entity_name} er stengt",
"is_closing": "{entity_name} stenges",
diff --git a/homeassistant/components/cover/.translations/ru.json b/homeassistant/components/cover/.translations/ru.json
index ebe81486cf5..97a8a8ba1bb 100644
--- a/homeassistant/components/cover/.translations/ru.json
+++ b/homeassistant/components/cover/.translations/ru.json
@@ -1,5 +1,11 @@
{
"device_automation": {
+ "action_type": {
+ "close": "\u0417\u0430\u043a\u0440\u044b\u0442\u044c {entity_name}",
+ "open": "\u041e\u0442\u043a\u0440\u044b\u0442\u044c {entity_name}",
+ "set_position": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 {entity_name}",
+ "set_tilt_position": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043d\u0430\u043a\u043b\u043e\u043d\u0430 {entity_name}"
+ },
"condition_type": {
"is_closed": "{entity_name} \u0432 \u0437\u0430\u043a\u0440\u044b\u0442\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438",
"is_closing": "{entity_name} \u0437\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f",
diff --git a/homeassistant/components/cover/.translations/zh-Hant.json b/homeassistant/components/cover/.translations/zh-Hant.json
index 790df01d9fc..d91010e974e 100644
--- a/homeassistant/components/cover/.translations/zh-Hant.json
+++ b/homeassistant/components/cover/.translations/zh-Hant.json
@@ -1,5 +1,13 @@
{
"device_automation": {
+ "action_type": {
+ "close": "\u95dc\u9589{entity_name}",
+ "close_tilt": "\u95dc\u9589{entity_name}\u7a97\u7c3e",
+ "open": "\u958b\u555f{entity_name}",
+ "open_tilt": "\u958b\u555f{entity_name}\u7a97\u7c3e",
+ "set_position": "\u8a2d\u5b9a{entity_name}\u4f4d\u7f6e",
+ "set_tilt_position": "\u8a2d\u5b9a{entity_name}\u5e8a\u7c3e\u4f4d\u7f6e"
+ },
"condition_type": {
"is_closed": "{entity_name}\u5df2\u95dc\u9589",
"is_closing": "{entity_name}\u6b63\u5728\u95dc\u9589",
diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py
new file mode 100644
index 00000000000..dba4ff8be89
--- /dev/null
+++ b/homeassistant/components/cover/device_action.py
@@ -0,0 +1,176 @@
+"""Provides device automations for Cover."""
+from typing import List, Optional
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ ATTR_SUPPORTED_FEATURES,
+ CONF_DEVICE_ID,
+ CONF_DOMAIN,
+ CONF_ENTITY_ID,
+ CONF_TYPE,
+ SERVICE_CLOSE_COVER,
+ SERVICE_CLOSE_COVER_TILT,
+ SERVICE_OPEN_COVER,
+ SERVICE_OPEN_COVER_TILT,
+ SERVICE_SET_COVER_POSITION,
+ SERVICE_SET_COVER_TILT_POSITION,
+)
+from homeassistant.core import Context, HomeAssistant
+from homeassistant.helpers import entity_registry
+import homeassistant.helpers.config_validation as cv
+
+from . import (
+ ATTR_POSITION,
+ ATTR_TILT_POSITION,
+ DOMAIN,
+ SUPPORT_CLOSE,
+ SUPPORT_CLOSE_TILT,
+ SUPPORT_OPEN,
+ SUPPORT_OPEN_TILT,
+ SUPPORT_SET_POSITION,
+ SUPPORT_SET_TILT_POSITION,
+)
+
+CMD_ACTION_TYPES = {"open", "close", "open_tilt", "close_tilt"}
+POSITION_ACTION_TYPES = {"set_position", "set_tilt_position"}
+
+CMD_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
+ {
+ vol.Required(CONF_TYPE): vol.In(CMD_ACTION_TYPES),
+ vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN),
+ }
+)
+
+POSITION_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
+ {
+ vol.Required(CONF_TYPE): vol.In(POSITION_ACTION_TYPES),
+ vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN),
+ vol.Required("position"): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)),
+ }
+)
+
+ACTION_SCHEMA = vol.Any(CMD_ACTION_SCHEMA, POSITION_ACTION_SCHEMA)
+
+
+async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]:
+ """List device actions for Cover devices."""
+ registry = await entity_registry.async_get_registry(hass)
+ actions = []
+
+ # 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)
+ if not state or ATTR_SUPPORTED_FEATURES not in state.attributes:
+ continue
+
+ supported_features = state.attributes[ATTR_SUPPORTED_FEATURES]
+
+ # Add actions for each entity that belongs to this integration
+ if supported_features & SUPPORT_SET_POSITION:
+ actions.append(
+ {
+ CONF_DEVICE_ID: device_id,
+ CONF_DOMAIN: DOMAIN,
+ CONF_ENTITY_ID: entry.entity_id,
+ CONF_TYPE: "set_position",
+ }
+ )
+ else:
+ if supported_features & SUPPORT_OPEN:
+ actions.append(
+ {
+ CONF_DEVICE_ID: device_id,
+ CONF_DOMAIN: DOMAIN,
+ CONF_ENTITY_ID: entry.entity_id,
+ CONF_TYPE: "open",
+ }
+ )
+ if supported_features & SUPPORT_CLOSE:
+ actions.append(
+ {
+ CONF_DEVICE_ID: device_id,
+ CONF_DOMAIN: DOMAIN,
+ CONF_ENTITY_ID: entry.entity_id,
+ CONF_TYPE: "close",
+ }
+ )
+
+ if supported_features & SUPPORT_SET_TILT_POSITION:
+ actions.append(
+ {
+ CONF_DEVICE_ID: device_id,
+ CONF_DOMAIN: DOMAIN,
+ CONF_ENTITY_ID: entry.entity_id,
+ CONF_TYPE: "set_tilt_position",
+ }
+ )
+ else:
+ if supported_features & SUPPORT_OPEN_TILT:
+ actions.append(
+ {
+ CONF_DEVICE_ID: device_id,
+ CONF_DOMAIN: DOMAIN,
+ CONF_ENTITY_ID: entry.entity_id,
+ CONF_TYPE: "open_tilt",
+ }
+ )
+ if supported_features & SUPPORT_CLOSE_TILT:
+ actions.append(
+ {
+ CONF_DEVICE_ID: device_id,
+ CONF_DOMAIN: DOMAIN,
+ CONF_ENTITY_ID: entry.entity_id,
+ CONF_TYPE: "close_tilt",
+ }
+ )
+
+ return actions
+
+
+async def async_get_action_capabilities(hass: HomeAssistant, config: dict) -> dict:
+ """List action capabilities."""
+ if config[CONF_TYPE] not in POSITION_ACTION_TYPES:
+ return {}
+
+ return {
+ "extra_fields": vol.Schema(
+ {
+ vol.Optional("position", default=0): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=100)
+ )
+ }
+ )
+ }
+
+
+async def async_call_action_from_config(
+ hass: HomeAssistant, config: dict, variables: dict, context: Optional[Context]
+) -> None:
+ """Execute a device action."""
+ config = ACTION_SCHEMA(config)
+
+ service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]}
+
+ if config[CONF_TYPE] == "open":
+ service = SERVICE_OPEN_COVER
+ elif config[CONF_TYPE] == "close":
+ service = SERVICE_CLOSE_COVER
+ elif config[CONF_TYPE] == "open_tilt":
+ service = SERVICE_OPEN_COVER_TILT
+ elif config[CONF_TYPE] == "close_tilt":
+ service = SERVICE_CLOSE_COVER_TILT
+ elif config[CONF_TYPE] == "set_position":
+ service = SERVICE_SET_COVER_POSITION
+ service_data[ATTR_POSITION] = config["position"]
+ elif config[CONF_TYPE] == "set_tilt_position":
+ service = SERVICE_SET_COVER_TILT_POSITION
+ service_data[ATTR_TILT_POSITION] = config["position"]
+
+ await hass.services.async_call(
+ DOMAIN, service, service_data, blocking=True, context=context
+ )
diff --git a/homeassistant/components/cover/manifest.json b/homeassistant/components/cover/manifest.json
index aa43e934dc9..788d72b707f 100644
--- a/homeassistant/components/cover/manifest.json
+++ b/homeassistant/components/cover/manifest.json
@@ -3,7 +3,7 @@
"name": "Cover",
"documentation": "https://www.home-assistant.io/integrations/cover",
"requirements": [],
- "dependencies": ["group"],
+ "dependencies": [],
"codeowners": ["@home-assistant/core"],
"quality_scale": "internal"
}
diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json
index 36492cc5ed5..90dac7c7d02 100644
--- a/homeassistant/components/cover/strings.json
+++ b/homeassistant/components/cover/strings.json
@@ -1,5 +1,13 @@
{
"device_automation": {
+ "action_type": {
+ "open": "Open {entity_name}",
+ "close": "Close {entity_name}",
+ "open_tilt": "Open {entity_name} tilt",
+ "close_tilt": "Close {entity_name} tilt",
+ "set_position": "Set {entity_name} position",
+ "set_tilt_position": "Set {entity_name} tilt position"
+ },
"condition_type": {
"is_open": "{entity_name} is open",
"is_closed": "{entity_name} is closed",
diff --git a/homeassistant/components/cups/sensor.py b/homeassistant/components/cups/sensor.py
index 7581891af6a..ac158388242 100644
--- a/homeassistant/components/cups/sensor.py
+++ b/homeassistant/components/cups/sensor.py
@@ -6,7 +6,7 @@ import logging
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.const import CONF_HOST, CONF_PORT, UNIT_PERCENTAGE
from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -268,7 +268,7 @@ class MarkerSensor(Entity):
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
- return "%"
+ return UNIT_PERCENTAGE
@property
def device_state_attributes(self):
diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py
index f83566e66e8..e3e2e6a0f27 100644
--- a/homeassistant/components/daikin/sensor.py
+++ b/homeassistant/components/daikin/sensor.py
@@ -47,9 +47,9 @@ class DaikinClimateSensor(Entity):
self._api = api
self._sensor = SENSOR_TYPES.get(monitored_state)
if name is None:
- name = "{} {}".format(self._sensor[CONF_NAME], api.name)
+ name = f"{self._sensor[CONF_NAME]} {api.name}"
- self._name = "{} {}".format(name, monitored_state.replace("_", " "))
+ self._name = f"{name} {monitored_state.replace('_', ' ')}"
self._device_attribute = monitored_state
if self._sensor[CONF_TYPE] == SENSOR_TYPE_TEMPERATURE:
diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py
index 4d3b0d3eade..e22c0b04995 100644
--- a/homeassistant/components/daikin/switch.py
+++ b/homeassistant/components/daikin/switch.py
@@ -54,7 +54,7 @@ class DaikinZoneSwitch(ToggleEntity):
@property
def name(self):
"""Return the name of the sensor."""
- return "{} {}".format(self._api.name, self._api.device.zones[self._zone_id][0])
+ return f"{self._api.name} {self._api.device.zones[self._zone_id][0]}"
@property
def is_on(self):
diff --git a/homeassistant/components/danfoss_air/sensor.py b/homeassistant/components/danfoss_air/sensor.py
index 247eb955154..73305d5c625 100644
--- a/homeassistant/components/danfoss_air/sensor.py
+++ b/homeassistant/components/danfoss_air/sensor.py
@@ -6,6 +6,7 @@ from homeassistant.const import (
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers.entity import Entity
@@ -45,14 +46,24 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
ReadCommand.extractTemperature,
DEVICE_CLASS_TEMPERATURE,
],
- ["Danfoss Air Remaining Filter", "%", ReadCommand.filterPercent, None],
- ["Danfoss Air Humidity", "%", ReadCommand.humidity, DEVICE_CLASS_HUMIDITY],
- ["Danfoss Air Fan Step", "%", ReadCommand.fan_step, None],
+ [
+ "Danfoss Air Remaining Filter",
+ UNIT_PERCENTAGE,
+ ReadCommand.filterPercent,
+ None,
+ ],
+ [
+ "Danfoss Air Humidity",
+ UNIT_PERCENTAGE,
+ ReadCommand.humidity,
+ DEVICE_CLASS_HUMIDITY,
+ ],
+ ["Danfoss Air Fan Step", UNIT_PERCENTAGE, ReadCommand.fan_step, None],
["Danfoss Air Exhaust Fan Speed", "RPM", ReadCommand.exhaust_fan_speed, None],
["Danfoss Air Supply Fan Speed", "RPM", ReadCommand.supply_fan_speed, None],
[
"Danfoss Air Dial Battery",
- "%",
+ UNIT_PERCENTAGE,
ReadCommand.battery_percent,
DEVICE_CLASS_BATTERY,
],
diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py
index 9f99b37a201..26ba590888f 100644
--- a/homeassistant/components/darksky/sensor.py
+++ b/homeassistant/components/darksky/sensor.py
@@ -15,6 +15,11 @@ from homeassistant.const import (
CONF_MONITORED_CONDITIONS,
CONF_NAME,
CONF_SCAN_INTERVAL,
+ SPEED_KILOMETERS_PER_HOUR,
+ SPEED_METERS_PER_SECOND,
+ SPEED_MILES_PER_HOUR,
+ TIME_HOURS,
+ UNIT_PERCENTAGE,
UNIT_UV_INDEX,
)
import homeassistant.helpers.config_validation as cv
@@ -99,21 +104,21 @@ SENSOR_TYPES = {
],
"precip_intensity": [
"Precip Intensity",
- "mm/h",
+ f"mm/{TIME_HOURS}",
"in",
- "mm/h",
- "mm/h",
- "mm/h",
+ f"mm/{TIME_HOURS}",
+ f"mm/{TIME_HOURS}",
+ f"mm/{TIME_HOURS}",
"mdi:weather-rainy",
["currently", "minutely", "hourly", "daily"],
],
"precip_probability": [
"Precip Probability",
- "%",
- "%",
- "%",
- "%",
- "%",
+ UNIT_PERCENTAGE,
+ UNIT_PERCENTAGE,
+ UNIT_PERCENTAGE,
+ UNIT_PERCENTAGE,
+ UNIT_PERCENTAGE,
"mdi:water-percent",
["currently", "minutely", "hourly", "daily"],
],
@@ -159,11 +164,11 @@ SENSOR_TYPES = {
],
"wind_speed": [
"Wind Speed",
- "m/s",
- "mph",
- "km/h",
- "mph",
- "mph",
+ SPEED_METERS_PER_SECOND,
+ SPEED_MILES_PER_HOUR,
+ SPEED_KILOMETERS_PER_HOUR,
+ SPEED_MILES_PER_HOUR,
+ SPEED_MILES_PER_HOUR,
"mdi:weather-windy",
["currently", "hourly", "daily"],
],
@@ -179,31 +184,31 @@ SENSOR_TYPES = {
],
"wind_gust": [
"Wind Gust",
- "m/s",
- "mph",
- "km/h",
- "mph",
- "mph",
+ SPEED_METERS_PER_SECOND,
+ SPEED_MILES_PER_HOUR,
+ SPEED_KILOMETERS_PER_HOUR,
+ SPEED_MILES_PER_HOUR,
+ SPEED_MILES_PER_HOUR,
"mdi:weather-windy-variant",
["currently", "hourly", "daily"],
],
"cloud_cover": [
"Cloud Coverage",
- "%",
- "%",
- "%",
- "%",
- "%",
+ UNIT_PERCENTAGE,
+ UNIT_PERCENTAGE,
+ UNIT_PERCENTAGE,
+ UNIT_PERCENTAGE,
+ UNIT_PERCENTAGE,
"mdi:weather-partly-cloudy",
["currently", "hourly", "daily"],
],
"humidity": [
"Humidity",
- "%",
- "%",
- "%",
- "%",
- "%",
+ UNIT_PERCENTAGE,
+ UNIT_PERCENTAGE,
+ UNIT_PERCENTAGE,
+ UNIT_PERCENTAGE,
+ UNIT_PERCENTAGE,
"mdi:water-percent",
["currently", "hourly", "daily"],
],
@@ -319,11 +324,11 @@ SENSOR_TYPES = {
],
"precip_intensity_max": [
"Daily Max Precip Intensity",
- "mm/h",
+ f"mm/{TIME_HOURS}",
"in",
- "mm/h",
- "mm/h",
- "mm/h",
+ f"mm/{TIME_HOURS}",
+ f"mm/{TIME_HOURS}",
+ f"mm/{TIME_HOURS}",
"mdi:thermometer",
["daily"],
],
diff --git a/homeassistant/components/datadog/__init__.py b/homeassistant/components/datadog/__init__.py
index adb8bb1f95c..52cbe906402 100644
--- a/homeassistant/components/datadog/__init__.py
+++ b/homeassistant/components/datadog/__init__.py
@@ -61,8 +61,8 @@ def setup(hass, config):
title="Home Assistant",
text=f"%%% \n **{name}** {message} \n %%%",
tags=[
- "entity:{}".format(event.data.get("entity_id")),
- "domain:{}".format(event.data.get("domain")),
+ f"entity:{event.data.get('entity_id')}",
+ f"domain:{event.data.get('domain')}",
],
)
@@ -84,7 +84,7 @@ def setup(hass, config):
for key, value in states.items():
if isinstance(value, (float, int)):
- attribute = "{}.{}".format(metric, key.replace(" ", "_"))
+ attribute = f"{metric}.{key.replace(' ', '_')}"
statsd.gauge(attribute, value, sample_rate=sample_rate, tags=tags)
_LOGGER.debug("Sent metric %s: %s (tags: %s)", attribute, value, tags)
diff --git a/homeassistant/components/deconz/.translations/es.json b/homeassistant/components/deconz/.translations/es.json
index cfff05b1e02..047be1c7933 100644
--- a/homeassistant/components/deconz/.translations/es.json
+++ b/homeassistant/components/deconz/.translations/es.json
@@ -66,16 +66,16 @@
},
"trigger_type": {
"remote_awakened": "Dispositivo despertado",
- "remote_button_double_press": "Bot\u00f3n \"{subtype}\" pulsado dos veces consecutivas",
+ "remote_button_double_press": "Bot\u00f3n \"{subtype}\" doble pulsaci\u00f3n",
"remote_button_long_press": "Bot\u00f3n \"{subtype}\" pulsado continuamente",
- "remote_button_long_release": "Bot\u00f3n \"{subtype}\" liberado despu\u00e9s de un rato pulsado",
- "remote_button_quadruple_press": "Bot\u00f3n \"{subtype}\" pulsado cuatro veces consecutivas",
- "remote_button_quintuple_press": "Bot\u00f3n \"{subtype}\" pulsado cinco veces consecutivas",
+ "remote_button_long_release": "Bot\u00f3n \"{subtype}\" soltado despu\u00e9s de una pulsaci\u00f3n larga",
+ "remote_button_quadruple_press": "Bot\u00f3n \"{subtype}\" cu\u00e1druple pulsaci\u00f3n",
+ "remote_button_quintuple_press": "Bot\u00f3n \"{subtype}\" qu\u00edntuple pulsaci\u00f3n",
"remote_button_rotated": "Bot\u00f3n \"{subtype}\" girado",
"remote_button_rotation_stopped": "Bot\u00f3n rotativo \"{subtype}\" detenido",
"remote_button_short_press": "Bot\u00f3n \"{subtype}\" pulsado",
- "remote_button_short_release": "Bot\u00f3n \"{subtype}\" liberado",
- "remote_button_triple_press": "Bot\u00f3n \"{subtype}\" pulsado cuatro veces consecutivas",
+ "remote_button_short_release": "Bot\u00f3n \"{subtype}\" soltado",
+ "remote_button_triple_press": "Bot\u00f3n \"{subtype}\" triple pulsaci\u00f3n",
"remote_double_tap": "Dispositivo \" {subtype} \" doble pulsaci\u00f3n",
"remote_double_tap_any_side": "Dispositivo con doble toque en cualquier lado",
"remote_falling": "Dispositivo en ca\u00edda libre",
diff --git a/homeassistant/components/deconz/.translations/lv.json b/homeassistant/components/deconz/.translations/lv.json
new file mode 100644
index 00000000000..aceb121a360
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/lv.json
@@ -0,0 +1,12 @@
+{
+ "device_automation": {
+ "trigger_subtype": {
+ "both_buttons": "Abas pogas",
+ "button_1": "Pirm\u0101 poga",
+ "button_2": "Otr\u0101 poga",
+ "button_3": "Tre\u0161\u0101 poga",
+ "turn_off": "Izsl\u0113gt",
+ "turn_on": "Iesl\u0113gt"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py
index 11dbd07e86a..cd125613f21 100644
--- a/homeassistant/components/deconz/const.py
+++ b/homeassistant/components/deconz/const.py
@@ -31,13 +31,6 @@ NEW_LIGHT = "lights"
NEW_SCENE = "scenes"
NEW_SENSOR = "sensors"
-NEW_DEVICE = {
- NEW_GROUP: "deconz_new_group_{}",
- NEW_LIGHT: "deconz_new_light_{}",
- NEW_SCENE: "deconz_new_scene_{}",
- NEW_SENSOR: "deconz_new_sensor_{}",
-}
-
ATTR_DARK = "dark"
ATTR_OFFSET = "offset"
ATTR_ON = "on"
diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py
index e8322c18e9a..8ae0394f935 100644
--- a/homeassistant/components/deconz/device_trigger.py
+++ b/homeassistant/components/deconz/device_trigger.py
@@ -153,48 +153,48 @@ TRADFRI_WIRELESS_DIMMER = {
AQARA_CUBE_MODEL = "lumi.sensor_cube"
AQARA_CUBE_MODEL_ALT1 = "lumi.sensor_cube.aqgl01"
AQARA_CUBE = {
- (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_2): {CONF_EVENT: 6002},
- (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_3): {CONF_EVENT: 3002},
- (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_4): {CONF_EVENT: 4002},
- (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_5): {CONF_EVENT: 1002},
- (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_6): {CONF_EVENT: 5002},
- (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_1): {CONF_EVENT: 2006},
- (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_3): {CONF_EVENT: 3006},
- (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_4): {CONF_EVENT: 4006},
- (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_5): {CONF_EVENT: 1006},
- (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_6): {CONF_EVENT: 5006},
- (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_1): {CONF_EVENT: 2003},
- (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_2): {CONF_EVENT: 6003},
+ (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_2): {CONF_EVENT: 2001},
+ (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_3): {CONF_EVENT: 3001},
+ (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_4): {CONF_EVENT: 4001},
+ (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_5): {CONF_EVENT: 5001},
+ (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_6): {CONF_EVENT: 6001},
+ (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_1): {CONF_EVENT: 1002},
+ (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_3): {CONF_EVENT: 3002},
+ (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_4): {CONF_EVENT: 4002},
+ (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_5): {CONF_EVENT: 5002},
+ (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_6): {CONF_EVENT: 6002},
+ (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_1): {CONF_EVENT: 1003},
+ (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_2): {CONF_EVENT: 2003},
(CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_4): {CONF_EVENT: 4003},
- (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_5): {CONF_EVENT: 1003},
- (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_6): {CONF_EVENT: 5003},
- (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_1): {CONF_EVENT: 2004},
- (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_2): {CONF_EVENT: 6004},
+ (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_5): {CONF_EVENT: 5003},
+ (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_6): {CONF_EVENT: 6003},
+ (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_1): {CONF_EVENT: 1004},
+ (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_2): {CONF_EVENT: 2004},
(CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_3): {CONF_EVENT: 3004},
- (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_5): {CONF_EVENT: 1004},
- (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_6): {CONF_EVENT: 5004},
- (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_1): {CONF_EVENT: 2001},
- (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_2): {CONF_EVENT: 6001},
- (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_3): {CONF_EVENT: 3001},
- (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_4): {CONF_EVENT: 4001},
- (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_6): {CONF_EVENT: 5001},
- (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_1): {CONF_EVENT: 2005},
- (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_2): {CONF_EVENT: 6005},
- (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_3): {CONF_EVENT: 3005},
- (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_4): {CONF_EVENT: 4005},
- (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_5): {CONF_EVENT: 1005},
- (CONF_MOVE, CONF_SIDE_1): {CONF_EVENT: 2000},
- (CONF_MOVE, CONF_SIDE_2): {CONF_EVENT: 6000},
+ (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_5): {CONF_EVENT: 5004},
+ (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_6): {CONF_EVENT: 6004},
+ (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_1): {CONF_EVENT: 1005},
+ (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_2): {CONF_EVENT: 2005},
+ (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_3): {CONF_EVENT: 3005},
+ (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_4): {CONF_EVENT: 4005},
+ (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_6): {CONF_EVENT: 6005},
+ (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_1): {CONF_EVENT: 1006},
+ (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_2): {CONF_EVENT: 2006},
+ (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_3): {CONF_EVENT: 3006},
+ (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_4): {CONF_EVENT: 4006},
+ (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_5): {CONF_EVENT: 5006},
+ (CONF_MOVE, CONF_SIDE_1): {CONF_EVENT: 1000},
+ (CONF_MOVE, CONF_SIDE_2): {CONF_EVENT: 2000},
(CONF_MOVE, CONF_SIDE_3): {CONF_EVENT: 3000},
(CONF_MOVE, CONF_SIDE_4): {CONF_EVENT: 4000},
- (CONF_MOVE, CONF_SIDE_5): {CONF_EVENT: 1000},
- (CONF_MOVE, CONF_SIDE_6): {CONF_EVENT: 5000},
- (CONF_DOUBLE_TAP, CONF_SIDE_1): {CONF_EVENT: 2002},
- (CONF_DOUBLE_TAP, CONF_SIDE_2): {CONF_EVENT: 6002},
+ (CONF_MOVE, CONF_SIDE_5): {CONF_EVENT: 5000},
+ (CONF_MOVE, CONF_SIDE_6): {CONF_EVENT: 6000},
+ (CONF_DOUBLE_TAP, CONF_SIDE_1): {CONF_EVENT: 1001},
+ (CONF_DOUBLE_TAP, CONF_SIDE_2): {CONF_EVENT: 2002},
(CONF_DOUBLE_TAP, CONF_SIDE_3): {CONF_EVENT: 3003},
(CONF_DOUBLE_TAP, CONF_SIDE_4): {CONF_EVENT: 4004},
- (CONF_DOUBLE_TAP, CONF_SIDE_5): {CONF_EVENT: 1001},
- (CONF_DOUBLE_TAP, CONF_SIDE_6): {CONF_EVENT: 5005},
+ (CONF_DOUBLE_TAP, CONF_SIDE_5): {CONF_EVENT: 5005},
+ (CONF_DOUBLE_TAP, CONF_SIDE_6): {CONF_EVENT: 6006},
(CONF_AWAKE, ""): {CONF_GESTURE: 0},
(CONF_SHAKE, ""): {CONF_GESTURE: 1},
(CONF_FREE_FALL, ""): {CONF_GESTURE: 2},
diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py
index 0b69b82463c..b59c80a0dc8 100644
--- a/homeassistant/components/deconz/gateway.py
+++ b/homeassistant/components/deconz/gateway.py
@@ -19,8 +19,9 @@ from .const import (
DEFAULT_ALLOW_DECONZ_GROUPS,
DOMAIN,
LOGGER,
- NEW_DEVICE,
NEW_GROUP,
+ NEW_LIGHT,
+ NEW_SCENE,
NEW_SENSOR,
SUPPORTED_PLATFORMS,
)
@@ -186,7 +187,13 @@ class DeconzGateway:
@callback
def async_signal_new_device(self, device_type) -> str:
"""Gateway specific event to signal new device."""
- return NEW_DEVICE[device_type].format(self.bridgeid)
+ new_device = {
+ NEW_GROUP: f"deconz_new_group_{self.bridgeid}",
+ NEW_LIGHT: f"deconz_new_light_{self.bridgeid}",
+ NEW_SCENE: f"deconz_new_scene_{self.bridgeid}",
+ NEW_SENSOR: f"deconz_new_sensor_{self.bridgeid}",
+ }
+ return new_device[device_type]
@property
def signal_remove_entity(self) -> str:
diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py
index c32b26f299d..fd8ffeeaaf0 100644
--- a/homeassistant/components/deconz/sensor.py
+++ b/homeassistant/components/deconz/sensor.py
@@ -9,7 +9,12 @@ from pydeconz.sensor import (
Thermostat,
)
-from homeassistant.const import ATTR_TEMPERATURE, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY
+from homeassistant.const import (
+ ATTR_TEMPERATURE,
+ ATTR_VOLTAGE,
+ DEVICE_CLASS_BATTERY,
+ UNIT_PERCENTAGE,
+)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
@@ -194,7 +199,7 @@ class DeconzBattery(DeconzDevice):
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity."""
- return "%"
+ return UNIT_PERCENTAGE
@property
def device_state_attributes(self):
diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json
index e19b1262b74..be9cb8dcc97 100644
--- a/homeassistant/components/default_config/manifest.json
+++ b/homeassistant/components/default_config/manifest.json
@@ -19,7 +19,12 @@
"system_health",
"updater",
"zeroconf",
- "zone"
+ "zone",
+ "input_boolean",
+ "input_datetime",
+ "input_text",
+ "input_number",
+ "input_select"
],
"codeowners": []
}
diff --git a/homeassistant/components/demo/.translations/lv.json b/homeassistant/components/demo/.translations/lv.json
new file mode 100644
index 00000000000..b7bbb906508
--- /dev/null
+++ b/homeassistant/components/demo/.translations/lv.json
@@ -0,0 +1,5 @@
+{
+ "config": {
+ "title": "Demonstr\u0101cija"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py
index b6845d9d6a4..344ffbd9fd3 100644
--- a/homeassistant/components/demo/__init__.py
+++ b/homeassistant/components/demo/__init__.py
@@ -23,6 +23,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [
"media_player",
"sensor",
"switch",
+ "vacuum",
"water_heater",
]
@@ -124,19 +125,6 @@ async def async_setup(hass, config):
)
)
- # Set up weblink
- tasks.append(
- bootstrap.async_setup_component(
- hass,
- "weblink",
- {
- "weblink": {
- "entities": [{"name": "Router", "url": "http://192.168.1.1"}]
- }
- },
- )
- )
-
results = await asyncio.gather(*tasks)
if any(not result for result in results):
@@ -209,22 +197,6 @@ async def finish_setup(hass, config):
switches = sorted(hass.states.async_entity_ids("switch"))
lights = sorted(hass.states.async_entity_ids("light"))
- # Set up history graph
- await bootstrap.async_setup_component(
- hass,
- "history_graph",
- {
- "history_graph": {
- "switches": {
- "name": "Recent Switches",
- "entities": switches,
- "hours_to_show": 1,
- "refresh": 60,
- }
- }
- },
- )
-
# Set up scripts
await bootstrap.async_setup_component(
hass,
@@ -232,7 +204,7 @@ async def finish_setup(hass, config):
{
"script": {
"demo": {
- "alias": "Toggle {}".format(lights[0].split(".")[1]),
+ "alias": f"Toggle {lights[0].split('.')[1]}",
"sequence": [
{
"service": "light.turn_off",
diff --git a/homeassistant/components/demo/air_quality.py b/homeassistant/components/demo/air_quality.py
index 9fe0f675d9d..656e22259e1 100644
--- a/homeassistant/components/demo/air_quality.py
+++ b/homeassistant/components/demo/air_quality.py
@@ -27,7 +27,7 @@ class DemoAirQuality(AirQualityEntity):
@property
def name(self):
"""Return the name of the sensor."""
- return "{} {}".format("Demo Air Quality", self._name)
+ return f"Demo Air Quality {self._name}"
@property
def should_poll(self):
diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py
index a2c06b72986..ac7e53826b9 100644
--- a/homeassistant/components/demo/light.py
+++ b/homeassistant/components/demo/light.py
@@ -44,9 +44,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
effect_list=LIGHT_EFFECT_LIST,
effect=LIGHT_EFFECT_LIST[0],
),
- DemoLight(
- "light_2", "Ceiling Lights", True, True, LIGHT_COLORS[0], LIGHT_TEMPS[1]
- ),
+ DemoLight("light_2", "Ceiling Lights", True, True, ct=LIGHT_TEMPS[1]),
DemoLight(
"light_3", "Kitchen Lights", True, True, LIGHT_COLORS[1], LIGHT_TEMPS[0]
),
@@ -86,6 +84,10 @@ class DemoLight(Light):
self._effect_list = effect_list
self._effect = effect
self._available = True
+ if ct is not None and hs_color is None:
+ self._color_mode = "ct"
+ else:
+ self._color_mode = "hs"
@property
def device_info(self):
@@ -128,12 +130,16 @@ class DemoLight(Light):
@property
def hs_color(self) -> tuple:
"""Return the hs color value."""
- return self._hs_color
+ if self._color_mode == "hs":
+ return self._hs_color
+ return None
@property
def color_temp(self) -> int:
"""Return the CT color temperature."""
- return self._ct
+ if self._color_mode == "ct":
+ return self._ct
+ return None
@property
def white_value(self) -> int:
@@ -165,9 +171,11 @@ class DemoLight(Light):
self._state = True
if ATTR_HS_COLOR in kwargs:
+ self._color_mode = "hs"
self._hs_color = kwargs[ATTR_HS_COLOR]
if ATTR_COLOR_TEMP in kwargs:
+ self._color_mode = "ct"
self._ct = kwargs[ATTR_COLOR_TEMP]
if ATTR_BRIGHTNESS in kwargs:
diff --git a/homeassistant/components/demo/mailbox.py b/homeassistant/components/demo/mailbox.py
index ce9c5cc0ea6..860524dfd7c 100644
--- a/homeassistant/components/demo/mailbox.py
+++ b/homeassistant/components/demo/mailbox.py
@@ -26,7 +26,7 @@ class DemoMailbox(Mailbox):
txt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
for idx in range(0, 10):
msgtime = int(dt.as_timestamp(dt.utcnow()) - 3600 * 24 * (10 - idx))
- msgtxt = "Message {}. {}".format(idx + 1, txt * (1 + idx * (idx % 2)))
+ msgtxt = f"Message {idx + 1}. {txt * (1 + idx * (idx % 2))}"
msgsha = sha1(msgtxt.encode("utf-8")).hexdigest()
msg = {
"info": {
diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py
index 33fe4ee3647..7a8f4eb8fbe 100644
--- a/homeassistant/components/demo/media_player.py
+++ b/homeassistant/components/demo/media_player.py
@@ -48,7 +48,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
await async_setup_platform(hass, {}, async_add_entities)
-YOUTUBE_COVER_URL_FORMAT = "https://img.youtube.com/vi/{}/hqdefault.jpg"
SOUND_MODE_LIST = ["Dummy Music", "Dummy Movie"]
DEFAULT_SOUND_MODE = "Dummy Music"
@@ -238,7 +237,7 @@ class DemoYoutubePlayer(AbstractDemoPlayer):
@property
def media_image_url(self):
"""Return the image url of current playing media."""
- return YOUTUBE_COVER_URL_FORMAT.format(self.youtube_id)
+ return f"https://img.youtube.com/vi/{self.youtube_id}/hqdefault.jpg"
@property
def media_title(self):
diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py
index d2b2464468b..6805ebb5b56 100644
--- a/homeassistant/components/demo/sensor.py
+++ b/homeassistant/components/demo/sensor.py
@@ -4,6 +4,7 @@ from homeassistant.const import (
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers.entity import Entity
@@ -23,7 +24,12 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
12,
),
DemoSensor(
- "sensor_2", "Outside Humidity", 54, DEVICE_CLASS_HUMIDITY, "%", None
+ "sensor_2",
+ "Outside Humidity",
+ 54,
+ DEVICE_CLASS_HUMIDITY,
+ UNIT_PERCENTAGE,
+ None,
),
]
)
diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py
index 5c651198f5c..5050b2283b4 100644
--- a/homeassistant/components/demo/switch.py
+++ b/homeassistant/components/demo/switch.py
@@ -10,7 +10,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
async_add_entities(
[
DemoSwitch("swith1", "Decorative Lights", True, None, True),
- DemoSwitch("swith2", "AC", False, "mdi:air-conditioner", False),
+ DemoSwitch(
+ "swith2",
+ "AC",
+ False,
+ "mdi:air-conditioner",
+ False,
+ device_class="outlet",
+ ),
]
)
diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py
index fb64f17a452..0bdf3ed48f1 100644
--- a/homeassistant/components/demo/vacuum.py
+++ b/homeassistant/components/demo/vacuum.py
@@ -78,12 +78,12 @@ DEMO_VACUUM_STATE = "5_Fifth_floor"
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Demo config entry."""
- setup_platform(hass, {}, async_add_entities)
+ await async_setup_platform(hass, {}, async_add_entities)
-def setup_platform(hass, config, add_entities, discovery_info=None):
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Demo vacuums."""
- add_entities(
+ async_add_entities(
[
DemoVacuum(DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES),
DemoVacuum(DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES),
diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py
index 8cc1b2f95fd..b17c88fa828 100644
--- a/homeassistant/components/demo/weather.py
+++ b/homeassistant/components/demo/weather.py
@@ -106,7 +106,7 @@ class DemoWeather(WeatherEntity):
@property
def name(self):
"""Return the name of the sensor."""
- return "{} {}".format("Demo Weather", self._name)
+ return f"Demo Weather {self._name}"
@property
def should_poll(self):
diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json
index 1387875c02d..d13ae1d7701 100644
--- a/homeassistant/components/denonavr/manifest.json
+++ b/homeassistant/components/denonavr/manifest.json
@@ -2,7 +2,7 @@
"domain": "denonavr",
"name": "Denon AVR Network Receivers",
"documentation": "https://www.home-assistant.io/integrations/denonavr",
- "requirements": ["denonavr==0.7.12"],
+ "requirements": ["denonavr==0.8.0"],
"dependencies": [],
"codeowners": []
}
diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py
index 5e68b268685..202c5885887 100644
--- a/homeassistant/components/derivative/sensor.py
+++ b/homeassistant/components/derivative/sensor.py
@@ -11,6 +11,10 @@ from homeassistant.const import (
CONF_SOURCE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ TIME_DAYS,
+ TIME_HOURS,
+ TIME_MINUTES,
+ TIME_SECONDS,
)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
@@ -42,7 +46,12 @@ UNIT_PREFIXES = {
}
# SI Time prefixes
-UNIT_TIME = {"s": 1, "min": 60, "h": 60 * 60, "d": 24 * 60 * 60}
+UNIT_TIME = {
+ TIME_SECONDS: 1,
+ TIME_MINUTES: 60,
+ TIME_HOURS: 60 * 60,
+ TIME_DAYS: 24 * 60 * 60,
+}
ICON = "mdi:chart-line"
@@ -55,7 +64,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
vol.Required(CONF_SOURCE): cv.entity_id,
vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int),
vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES),
- vol.Optional(CONF_UNIT_TIME, default="h"): vol.In(UNIT_TIME),
+ vol.Optional(CONF_UNIT_TIME, default=TIME_HOURS): vol.In(UNIT_TIME),
vol.Optional(CONF_UNIT): cv.string,
vol.Optional(CONF_TIME_WINDOW, default=DEFAULT_TIME_WINDOW): cv.time_period,
}
diff --git a/homeassistant/components/deutsche_bahn/sensor.py b/homeassistant/components/deutsche_bahn/sensor.py
index 204518b2ce3..fd7496b1316 100644
--- a/homeassistant/components/deutsche_bahn/sensor.py
+++ b/homeassistant/components/deutsche_bahn/sensor.py
@@ -82,7 +82,7 @@ class DeutscheBahnSensor(Entity):
self.data.update()
self._state = self.data.connections[0].get("departure", "Unknown")
if self.data.connections[0].get("delay", 0) != 0:
- self._state += " + {}".format(self.data.connections[0]["delay"])
+ self._state += f" + {self.data.connections[0]['delay']}"
class SchieneData:
diff --git a/homeassistant/components/device_sun_light_trigger/manifest.json b/homeassistant/components/device_sun_light_trigger/manifest.json
index 702f8704564..edeb10dcec2 100644
--- a/homeassistant/components/device_sun_light_trigger/manifest.json
+++ b/homeassistant/components/device_sun_light_trigger/manifest.json
@@ -3,7 +3,8 @@
"name": "Presence-based Lights",
"documentation": "https://www.home-assistant.io/integrations/device_sun_light_trigger",
"requirements": [],
- "dependencies": ["device_tracker", "group", "light", "person"],
+ "dependencies": [],
+ "after_dependencies": ["device_tracker", "group", "light", "person"],
"codeowners": [],
"quality_scale": "internal"
}
diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py
index c66bb621ad4..6d8e2307145 100644
--- a/homeassistant/components/device_tracker/__init__.py
+++ b/homeassistant/components/device_tracker/__init__.py
@@ -25,12 +25,10 @@ from .const import (
ATTR_LOCATION_NAME,
ATTR_MAC,
ATTR_SOURCE_TYPE,
- CONF_AWAY_HIDE,
CONF_CONSIDER_HOME,
CONF_NEW_DEVICE_DEFAULTS,
CONF_SCAN_INTERVAL,
CONF_TRACK_NEW,
- DEFAULT_AWAY_HIDE,
DEFAULT_CONSIDER_HOME,
DEFAULT_TRACK_NEW,
DOMAIN,
@@ -53,15 +51,7 @@ SOURCE_TYPES = (
NEW_DEVICE_DEFAULTS_SCHEMA = vol.Any(
None,
- vol.All(
- cv.deprecated(CONF_AWAY_HIDE, invalidation_version="0.107.0"),
- vol.Schema(
- {
- vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean,
- vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean,
- }
- ),
- ),
+ vol.Schema({vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean}),
)
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(
{
diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py
index 059c51989fe..1be47b9b981 100644
--- a/homeassistant/components/device_tracker/config_entry.py
+++ b/homeassistant/components/device_tracker/config_entry.py
@@ -61,10 +61,15 @@ class BaseTrackerEntity(Entity):
class TrackerEntity(BaseTrackerEntity):
"""Represent a tracked device."""
+ @property
+ def should_poll(self):
+ """No polling for entities that have location pushed."""
+ return False
+
@property
def force_update(self):
- """All updates need to be written to the state machine."""
- return True
+ """All updates need to be written to the state machine if we're not polling."""
+ return not self.should_poll
@property
def location_accuracy(self):
diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py
index 1778a87b36a..c9ce9f2024a 100644
--- a/homeassistant/components/device_tracker/const.py
+++ b/homeassistant/components/device_tracker/const.py
@@ -5,7 +5,6 @@ import logging
LOGGER = logging.getLogger(__package__)
DOMAIN = "device_tracker"
-ENTITY_ID_FORMAT = DOMAIN + ".{}"
PLATFORM_TYPE_LEGACY = "legacy"
PLATFORM_TYPE_ENTITY = "entity_platform"
@@ -21,9 +20,6 @@ SCAN_INTERVAL = timedelta(seconds=12)
CONF_TRACK_NEW = "track_new_devices"
DEFAULT_TRACK_NEW = True
-CONF_AWAY_HIDE = "hide_if_away"
-DEFAULT_AWAY_HIDE = False
-
CONF_CONSIDER_HOME = "consider_home"
DEFAULT_CONSIDER_HOME = timedelta(seconds=180)
diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py
index 08bbed12519..515b7cbc614 100644
--- a/homeassistant/components/device_tracker/legacy.py
+++ b/homeassistant/components/device_tracker/legacy.py
@@ -37,15 +37,12 @@ from .const import (
ATTR_HOST_NAME,
ATTR_MAC,
ATTR_SOURCE_TYPE,
- CONF_AWAY_HIDE,
CONF_CONSIDER_HOME,
CONF_NEW_DEVICE_DEFAULTS,
CONF_TRACK_NEW,
- DEFAULT_AWAY_HIDE,
DEFAULT_CONSIDER_HOME,
DEFAULT_TRACK_NEW,
DOMAIN,
- ENTITY_ID_FORMAT,
LOGGER,
SOURCE_TYPE_GPS,
)
@@ -182,7 +179,7 @@ class DeviceTracker:
return
# Guard from calling see on entity registry entities.
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
if registry.async_is_registered(entity_id):
LOGGER.error(
"The see service is not supported for this entity %s", entity_id
@@ -199,7 +196,6 @@ class DeviceTracker:
mac,
picture=picture,
icon=icon,
- hide_if_away=self.defaults.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE),
)
self.devices[dev_id] = device
if mac is not None:
@@ -304,11 +300,10 @@ class Device(RestoreEntity):
picture: str = None,
gravatar: str = None,
icon: str = None,
- hide_if_away: bool = False,
) -> None:
"""Initialize a device."""
self.hass = hass
- self.entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ self.entity_id = f"{DOMAIN}.{dev_id}"
# Timedelta object how long we consider a device home if it is not
# detected anymore.
@@ -332,8 +327,6 @@ class Device(RestoreEntity):
self.icon = icon
- self.away_hide = hide_if_away
-
self.source_type = None
self._attributes = {}
@@ -373,11 +366,6 @@ class Device(RestoreEntity):
"""Return device state attributes."""
return self._attributes
- @property
- def hidden(self):
- """If device should be hidden."""
- return self.away_hide and self.state != STATE_HOME
-
async def async_seen(
self,
host_name: str = None,
@@ -412,7 +400,6 @@ class Device(RestoreEntity):
self.gps_accuracy = 0
LOGGER.warning("Could not parse gps value for %s: %s", self.dev_id, gps)
- # pylint: disable=not-an-iterable
await self.async_update()
def stale(self, now: dt_util.dt.datetime = None):
@@ -526,7 +513,6 @@ async def async_load_config(
vol.Optional(CONF_MAC, default=None): vol.Any(
None, vol.All(cv.string, vol.Upper)
),
- vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean,
vol.Optional("gravatar", default=None): vol.Any(None, cv.string),
vol.Optional("picture", default=None): vol.Any(None, cv.string),
vol.Optional(CONF_CONSIDER_HOME, default=consider_home): vol.All(
@@ -546,6 +532,7 @@ async def async_load_config(
for dev_id, device in devices.items():
# Deprecated option. We just ignore it to avoid breaking change
device.pop("vendor", None)
+ device.pop("hide_if_away", None)
try:
device = dev_schema(device)
device["dev_id"] = cv.slugify(dev_id)
@@ -566,7 +553,6 @@ def update_config(path: str, dev_id: str, device: Device):
ATTR_ICON: device.icon,
"picture": device.config_picture,
"track": device.track,
- CONF_AWAY_HIDE: device.away_hide,
}
}
out.write("\n")
@@ -579,5 +565,7 @@ def get_gravatar_for_email(email: str):
Async friendly.
"""
- url = "https://www.gravatar.com/avatar/{}.jpg?s=80&d=wavatar"
- return url.format(hashlib.md5(email.encode("utf-8").lower()).hexdigest())
+ return (
+ f"https://www.gravatar.com/avatar/"
+ f"{hashlib.md5(email.encode('utf-8').lower()).hexdigest()}.jpg?s=80&d=wavatar"
+ )
diff --git a/homeassistant/components/device_tracker/manifest.json b/homeassistant/components/device_tracker/manifest.json
index 35b9a4a3fdb..2d0e9a82a53 100644
--- a/homeassistant/components/device_tracker/manifest.json
+++ b/homeassistant/components/device_tracker/manifest.json
@@ -3,7 +3,8 @@
"name": "Device Tracker",
"documentation": "https://www.home-assistant.io/integrations/device_tracker",
"requirements": [],
- "dependencies": ["group", "zone"],
+ "dependencies": [],
+ "after_dependencies": ["zone"],
"codeowners": [],
"quality_scale": "internal"
}
diff --git a/homeassistant/components/device_tracker/setup.py b/homeassistant/components/device_tracker/setup.py
index 42751b1a784..595e36ef07c 100644
--- a/homeassistant/components/device_tracker/setup.py
+++ b/homeassistant/components/device_tracker/setup.py
@@ -109,9 +109,7 @@ async def async_extract_config(hass, config):
legacy.append(platform)
else:
raise ValueError(
- "Unable to determine type for {}: {}".format(
- platform.name, platform.type
- )
+ f"Unable to determine type for {platform.name}: {platform.type}"
)
return legacy
diff --git a/homeassistant/components/dht/sensor.py b/homeassistant/components/dht/sensor.py
index 26b0493cb99..b9461fae7d7 100644
--- a/homeassistant/components/dht/sensor.py
+++ b/homeassistant/components/dht/sensor.py
@@ -6,7 +6,12 @@ import Adafruit_DHT # pylint: disable=import-error
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_FAHRENHEIT
+from homeassistant.const import (
+ CONF_MONITORED_CONDITIONS,
+ CONF_NAME,
+ TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
+)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle
@@ -28,7 +33,7 @@ SENSOR_TEMPERATURE = "temperature"
SENSOR_HUMIDITY = "humidity"
SENSOR_TYPES = {
SENSOR_TEMPERATURE: ["Temperature", None],
- SENSOR_HUMIDITY: ["Humidity", "%"],
+ SENSOR_HUMIDITY: ["Humidity", UNIT_PERCENTAGE],
}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
diff --git a/homeassistant/components/directv/.translations/en.json b/homeassistant/components/directv/.translations/en.json
new file mode 100644
index 00000000000..667d5168f8d
--- /dev/null
+++ b/homeassistant/components/directv/.translations/en.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "DirecTV receiver is already configured",
+ "unknown": "Unexpected error"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again"
+ },
+ "flow_title": "DirecTV: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "data": {},
+ "description": "Do you want to set up {name}?",
+ "title": "Connect to the DirecTV receiver"
+ },
+ "user": {
+ "data": {
+ "host": "Host or IP address"
+ },
+ "title": "Connect to the DirecTV receiver"
+ }
+ },
+ "title": "DirecTV"
+ }
+}
diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py
index 5934e1b6c51..fc7bb78989a 100644
--- a/homeassistant/components/directv/__init__.py
+++ b/homeassistant/components/directv/__init__.py
@@ -1 +1,94 @@
-"""The directv component."""
+"""The DirecTV integration."""
+import asyncio
+from datetime import timedelta
+from typing import Dict
+
+from DirectPy import DIRECTV
+from requests.exceptions import RequestException
+import voluptuous as vol
+
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
+
+from .const import DATA_CLIENT, DATA_LOCATIONS, DATA_VERSION_INFO, DEFAULT_PORT, DOMAIN
+
+CONFIG_SCHEMA = vol.Schema(
+ {
+ DOMAIN: vol.All(
+ cv.ensure_list, [vol.Schema({vol.Required(CONF_HOST): cv.string})]
+ )
+ },
+ extra=vol.ALLOW_EXTRA,
+)
+
+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, {})
+
+ 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
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up DirecTV from a config entry."""
+ try:
+ dtv_data = await hass.async_add_executor_job(
+ get_dtv_data, hass, entry.data[CONF_HOST]
+ )
+ except RequestException:
+ raise ConfigEntryNotReady
+
+ hass.data[DOMAIN][entry.entry_id] = dtv_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)
+
+ return unload_ok
diff --git a/homeassistant/components/directv/config_flow.py b/homeassistant/components/directv/config_flow.py
new file mode 100644
index 00000000000..d1b3a6cbe62
--- /dev/null
+++ b/homeassistant/components/directv/config_flow.py
@@ -0,0 +1,128 @@
+"""Config flow for DirecTV."""
+import logging
+from typing import Any, Dict, Optional
+from urllib.parse import urlparse
+
+from DirectPy import DIRECTV
+from requests.exceptions import RequestException
+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 .const import DEFAULT_PORT
+from .const import DOMAIN # pylint: disable=unused-import
+
+_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:
+ """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()
+
+ return {
+ "title": data["host"],
+ "host": data["host"],
+ "receiver_id": "".join(version_info["receiverId"].split()),
+ }
+
+
+class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for DirecTV."""
+
+ 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 a flow initialized 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 user."""
+ if not user_input:
+ return self._show_form()
+
+ errors = {}
+
+ 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)
+ 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()
+
+ 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
+ receiver_id = discovery_info[ATTR_UPNP_SERIAL][4:] # strips off RID-
+
+ await self.async_set_unique_id(receiver_id)
+ 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: host, "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)
+
+ 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},
+ )
+
+
+class CannotConnect(HomeAssistantError):
+ """Error to indicate we cannot connect."""
diff --git a/homeassistant/components/directv/const.py b/homeassistant/components/directv/const.py
new file mode 100644
index 00000000000..e5b04ce34f6
--- /dev/null
+++ b/homeassistant/components/directv/const.py
@@ -0,0 +1,20 @@
+"""Constants for the DirecTV integration."""
+
+DOMAIN = "directv"
+
+ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording"
+ATTR_MEDIA_RATING = "media_rating"
+ATTR_MEDIA_RECORDED = "media_recorded"
+ATTR_MEDIA_START_TIME = "media_start_time"
+
+DATA_CLIENT = "client"
+DATA_LOCATIONS = "locations"
+DATA_VERSION_INFO = "version_info"
+
+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 b0f0f8bb5eb..cb8ed68b304 100644
--- a/homeassistant/components/directv/manifest.json
+++ b/homeassistant/components/directv/manifest.json
@@ -2,7 +2,14 @@
"domain": "directv",
"name": "DirecTV",
"documentation": "https://www.home-assistant.io/integrations/directv",
- "requirements": ["directpy==0.6"],
+ "requirements": ["directpy==0.7"],
"dependencies": [],
- "codeowners": []
+ "codeowners": ["@ctalkington"],
+ "config_flow": true,
+ "ssdp": [
+ {
+ "manufacturer": "DIRECTV",
+ "deviceType": "urn:schemas-upnp-org:device:MediaServer:1"
+ }
+ ]
}
diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py
index cd4f910c707..f487e72f694 100644
--- a/homeassistant/components/directv/media_player.py
+++ b/homeassistant/components/directv/media_player.py
@@ -1,8 +1,9 @@
"""Support for the DirecTV receivers."""
import logging
+from typing import Callable, Dict, List, Optional
from DirectPy import DIRECTV
-import requests
+from requests.exceptions import RequestException
import voluptuous as vol
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice
@@ -19,6 +20,7 @@ from homeassistant.components.media_player.const import (
SUPPORT_TURN_OFF,
SUPPORT_TURN_ON,
)
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DEVICE,
CONF_HOST,
@@ -28,20 +30,29 @@ from homeassistant.const import (
STATE_PAUSED,
STATE_PLAYING,
)
-import homeassistant.helpers.config_validation as cv
-import homeassistant.util.dt as dt_util
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.util import dt as dt_util
+
+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__)
-ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording"
-ATTR_MEDIA_RATING = "media_rating"
-ATTR_MEDIA_RECORDED = "media_recorded"
-ATTR_MEDIA_START_TIME = "media_start_time"
-
-DEFAULT_DEVICE = "0"
-DEFAULT_NAME = "DirecTV Receiver"
-DEFAULT_PORT = 8080
-
SUPPORT_DTV = (
SUPPORT_PAUSE
| SUPPORT_TURN_ON
@@ -62,8 +73,6 @@ SUPPORT_DTV_CLIENT = (
| SUPPORT_PLAY
)
-DATA_DIRECTV = "data_directv"
-
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
@@ -74,91 +83,54 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
-def setup_platform(hass, config, add_entities, discovery_info=None):
- """Set up the DirecTV platform."""
- known_devices = hass.data.get(DATA_DIRECTV, set())
- hosts = []
+async def async_setup_entry(
+ hass: HomeAssistantType,
+ entry: ConfigEntry,
+ 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]
+ entities = []
- if CONF_HOST in config:
- _LOGGER.debug(
- "Adding configured device %s with client address %s ",
- config.get(CONF_NAME),
- config.get(CONF_DEVICE),
- )
- hosts.append(
- [
- config.get(CONF_NAME),
- config.get(CONF_HOST),
- config.get(CONF_PORT),
- config.get(CONF_DEVICE),
- ]
+ 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]
+
+ entities.append(
+ DirecTvDevice(
+ str.title(loc["locationName"]), loc["clientAddr"], dtv, version_info,
+ )
)
- elif discovery_info:
- host = discovery_info.get("host")
- name = "DirecTV_{}".format(discovery_info.get("serial", ""))
-
- # Attempt to discover additional RVU units
- _LOGGER.debug("Doing discovery of DirecTV devices on %s", host)
-
- dtv = DIRECTV(host, DEFAULT_PORT)
- try:
- resp = dtv.get_locations()
- except requests.exceptions.RequestException as ex:
- # Bail out and just go forward with uPnP data
- # Make sure that this device is not already configured
- # Comparing based on host (IP) and clientAddr.
- _LOGGER.debug("Request exception %s trying to get locations", ex)
- resp = {"locations": [{"locationName": name, "clientAddr": DEFAULT_DEVICE}]}
-
- _LOGGER.debug("Known devices: %s", known_devices)
- for loc in resp.get("locations") or []:
- if "locationName" not in loc or "clientAddr" not in loc:
- continue
-
- # Make sure that this device is not already configured
- # Comparing based on host (IP) and clientAddr.
- if (host, loc["clientAddr"]) in known_devices:
- _LOGGER.debug(
- "Discovered device %s on host %s with "
- "client address %s is already "
- "configured",
- str.title(loc["locationName"]),
- host,
- loc["clientAddr"],
- )
- else:
- _LOGGER.debug(
- "Adding discovered device %s with client address %s",
- str.title(loc["locationName"]),
- loc["clientAddr"],
- )
- hosts.append(
- [
- str.title(loc["locationName"]),
- host,
- DEFAULT_PORT,
- loc["clientAddr"],
- ]
- )
-
- dtvs = []
-
- for host in hosts:
- dtvs.append(DirecTvDevice(*host))
- hass.data.setdefault(DATA_DIRECTV, set()).add((host[1], host[3]))
-
- add_entities(dtvs)
+ async_add_entities(entities, True)
class DirecTvDevice(MediaPlayerDevice):
"""Representation of a DirecTV receiver on the network."""
- def __init__(self, name, host, port, device):
+ def __init__(
+ self,
+ name: str,
+ device: str,
+ dtv: DIRECTV,
+ version_info: Optional[Dict] = None,
+ enabled_default: bool = True,
+ ):
"""Initialize the device."""
-
- self.dtv = DIRECTV(host, port, device)
+ self.dtv = dtv
self._name = name
+ self._unique_id = None
self._is_standby = True
self._current = None
self._last_update = None
@@ -168,12 +140,23 @@ class DirecTvDevice(MediaPlayerDevice):
self._is_client = device != "0"
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
if self._is_client:
- _LOGGER.debug("Created DirecTV client %s for device %s", self._name, device)
- else:
- _LOGGER.debug("Created DirecTV device for %s", self._name)
+ 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):
"""Retrieve latest state."""
@@ -204,25 +187,25 @@ class DirecTvDevice(MediaPlayerDevice):
else:
# If an error is received then only set to unavailable if
# this started at least 1 minute ago.
- log_message = "{}: Invalid status {} received".format(
- self.entity_id, self._current["status"]["code"]
- )
+ log_message = f"{self.entity_id}: Invalid status {self._current['status']['code']} received"
if self._check_state_available():
_LOGGER.debug(log_message)
else:
_LOGGER.error(log_message)
- except requests.RequestException as ex:
+ except RequestException as exception:
_LOGGER.error(
"%s: Request error trying to update current status: %s",
self.entity_id,
- ex,
+ exception,
)
self._check_state_available()
- except Exception as ex:
+ except Exception as exception:
_LOGGER.error(
- "%s: Exception trying to update current status: %s", self.entity_id, ex
+ "%s: Exception trying to update current status: %s",
+ self.entity_id,
+ exception,
)
self._available = False
if not self._first_error_timestamp:
@@ -257,6 +240,28 @@ class DirecTvDevice(MediaPlayerDevice):
"""Return the name of the device."""
return self._name
+ @property
+ def unique_id(self):
+ """Return a unique ID to use for this media player."""
+ return self._unique_id
+
+ @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
+
# MediaPlayerDevice properties and methods
@property
def state(self):
@@ -350,7 +355,7 @@ class DirecTvDevice(MediaPlayerDevice):
if self._is_standby:
return None
- return "{} ({})".format(self._current["callsign"], self._current["major"])
+ return f"{self._current['callsign']} ({self._current['major']})"
@property
def source(self):
diff --git a/homeassistant/components/directv/strings.json b/homeassistant/components/directv/strings.json
new file mode 100644
index 00000000000..e0a5a477ad2
--- /dev/null
+++ b/homeassistant/components/directv/strings.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "title": "DirecTV",
+ "flow_title": "DirecTV: {name}",
+ "step": {
+ "ssdp_confirm": {
+ "data": {},
+ "description": "Do you want to set up {name}?",
+ "title": "Connect to the DirecTV receiver"
+ },
+ "user": {
+ "title": "Connect to the DirecTV receiver",
+ "data": {
+ "host": "Host or IP address"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again"
+ },
+ "abort": {
+ "already_configured": "DirecTV receiver is already configured",
+ "unknown": "Unexpected error"
+ }
+ }
+}
diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py
index 1e29d066f2d..d12e9d2c54b 100644
--- a/homeassistant/components/discovery/__init__.py
+++ b/homeassistant/components/discovery/__init__.py
@@ -37,7 +37,6 @@ SERVICE_KONNECTED = "konnected"
SERVICE_MOBILE_APP = "hass_mobile_app"
SERVICE_NETGEAR = "netgear_router"
SERVICE_OCTOPRINT = "octoprint"
-SERVICE_PLEX = "plex_mediaserver"
SERVICE_ROKU = "roku"
SERVICE_SABNZBD = "sabnzbd"
SERVICE_SAMSUNG_PRINTER = "samsung_printer"
@@ -51,7 +50,6 @@ CONFIG_ENTRY_HANDLERS = {
SERVICE_DAIKIN: "daikin",
SERVICE_TELLDUSLIVE: "tellduslive",
SERVICE_IGD: "upnp",
- SERVICE_PLEX: "plex",
}
SERVICE_HANDLERS = {
diff --git a/homeassistant/components/dlib_face_detect/image_processing.py b/homeassistant/components/dlib_face_detect/image_processing.py
index 430878ca44f..9e56668eb3e 100644
--- a/homeassistant/components/dlib_face_detect/image_processing.py
+++ b/homeassistant/components/dlib_face_detect/image_processing.py
@@ -45,7 +45,7 @@ class DlibFaceDetectEntity(ImageProcessingFaceEntity):
if name:
self._name = name
else:
- self._name = "Dlib Face {0}".format(split_entity_id(camera_entity)[1])
+ self._name = f"Dlib Face {split_entity_id(camera_entity)[1]}"
@property
def camera_entity(self):
diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py
index d6fbf106b0c..32c2aa5868c 100644
--- a/homeassistant/components/dlib_face_identify/image_processing.py
+++ b/homeassistant/components/dlib_face_identify/image_processing.py
@@ -59,7 +59,7 @@ class DlibFaceIdentifyEntity(ImageProcessingFaceEntity):
if name:
self._name = name
else:
- self._name = "Dlib Face {0}".format(split_entity_id(camera_entity)[1])
+ self._name = f"Dlib Face {split_entity_id(camera_entity)[1]}"
self._faces = {}
for face_name, face_file in faces.items():
diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py
index 65a32938140..4130f67ec13 100644
--- a/homeassistant/components/doods/image_processing.py
+++ b/homeassistant/components/doods/image_processing.py
@@ -3,7 +3,7 @@ import io
import logging
import time
-from PIL import Image, ImageDraw
+from PIL import Image, ImageDraw, UnidentifiedImageError
from pydoods import PyDOODS
import voluptuous as vol
@@ -274,7 +274,11 @@ class Doods(ImageProcessingEntity):
def process_image(self, image):
"""Process the image."""
- img = Image.open(io.BytesIO(bytearray(image)))
+ try:
+ img = Image.open(io.BytesIO(bytearray(image))).convert("RGB")
+ except UnidentifiedImageError:
+ _LOGGER.warning("Unable to process image, bad data")
+ return
img_width, img_height = img.size
if self._aspect and abs((img_width / img_height) - self._aspect) > 0.1:
diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py
index d82e27f0f9a..049681a4aa6 100644
--- a/homeassistant/components/doorbird/__init__.py
+++ b/homeassistant/components/doorbird/__init__.py
@@ -66,7 +66,7 @@ def setup(hass, config):
custom_url = doorstation_config.get(CONF_CUSTOM_URL)
events = doorstation_config.get(CONF_EVENTS)
token = doorstation_config.get(CONF_TOKEN)
- name = doorstation_config.get(CONF_NAME) or "DoorBird {}".format(index + 1)
+ name = doorstation_config.get(CONF_NAME) or f"DoorBird {index + 1}"
try:
device = DoorBird(device_ip, username, password)
@@ -297,6 +297,6 @@ class DoorBirdRequestView(HomeAssistantView):
hass.bus.async_fire(f"{DOMAIN}_{event}", event_data)
- log_entry(hass, "Doorbird {}".format(event), "event was fired.", DOMAIN)
+ log_entry(hass, f"Doorbird {event}", "event was fired.", DOMAIN)
return web.Response(status=200, text="OK")
diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py
index d9a802f071f..4bf3a6e060f 100644
--- a/homeassistant/components/doorbird/camera.py
+++ b/homeassistant/components/doorbird/camera.py
@@ -12,9 +12,6 @@ import homeassistant.util.dt as dt_util
from . import DOMAIN as DOORBIRD_DOMAIN
-_CAMERA_LAST_VISITOR = "{} Last Ring"
-_CAMERA_LAST_MOTION = "{} Last Motion"
-_CAMERA_LIVE = "{} Live"
_LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=1)
_LAST_MOTION_INTERVAL = datetime.timedelta(minutes=1)
_LIVE_INTERVAL = datetime.timedelta(seconds=1)
@@ -30,18 +27,18 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
[
DoorBirdCamera(
device.live_image_url,
- _CAMERA_LIVE.format(doorstation.name),
+ f"{doorstation.name} Live",
_LIVE_INTERVAL,
device.rtsp_live_video_url,
),
DoorBirdCamera(
device.history_image_url(1, "doorbell"),
- _CAMERA_LAST_VISITOR.format(doorstation.name),
+ f"{doorstation.name} Last Ring",
_LAST_VISITOR_INTERVAL,
),
DoorBirdCamera(
device.history_image_url(1, "motionsensor"),
- _CAMERA_LAST_MOTION.format(doorstation.name),
+ f"{doorstation.name} Last Motion",
_LAST_MOTION_INTERVAL,
),
]
diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py
index ab85c376469..8328df8bd7f 100644
--- a/homeassistant/components/dovado/sensor.py
+++ b/homeassistant/components/dovado/sensor.py
@@ -6,7 +6,7 @@ import re
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_SENSORS, DATA_GIGABYTES
+from homeassistant.const import CONF_SENSORS, DATA_GIGABYTES, UNIT_PERCENTAGE
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -24,7 +24,12 @@ SENSOR_SMS_UNREAD = "sms"
SENSORS = {
SENSOR_NETWORK: ("signal strength", "Network", None, "mdi:access-point-network"),
- SENSOR_SIGNAL: ("signal strength", "Signal Strength", "%", "mdi:signal"),
+ SENSOR_SIGNAL: (
+ "signal strength",
+ "Signal Strength",
+ UNIT_PERCENTAGE,
+ "mdi:signal",
+ ),
SENSOR_SMS_UNREAD: ("sms unread", "SMS unread", "", "mdi:message-text-outline"),
SENSOR_UPLOAD: ("traffic modem tx", "Sent", DATA_GIGABYTES, "mdi:cloud-upload"),
SENSOR_DOWNLOAD: (
@@ -85,7 +90,7 @@ class DovadoSensor(Entity):
@property
def name(self):
"""Return the name of the sensor."""
- return "{} {}".format(self._data.name, SENSORS[self._sensor][1])
+ return f"{self._data.name} {SENSORS[self._sensor][1]}"
@property
def state(self):
diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py
index 009b86e1bb8..257407bb763 100644
--- a/homeassistant/components/dsmr/sensor.py
+++ b/homeassistant/components/dsmr/sensor.py
@@ -9,7 +9,12 @@ import serial
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_PORT,
+ EVENT_HOMEASSISTANT_STOP,
+ TIME_HOURS,
+)
from homeassistant.core import CoreState
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -303,4 +308,4 @@ class DerivativeDSMREntity(DSMREntity):
"""Return the unit of measurement of this entity, per hour, if any."""
unit = self.get_dsmr_object_attr("unit")
if unit:
- return unit + "/h"
+ return f"{unit}/{TIME_HOURS}"
diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py
index 45bebfeda92..bd583be37f4 100644
--- a/homeassistant/components/dsmr_reader/definitions.py
+++ b/homeassistant/components/dsmr_reader/definitions.py
@@ -1,5 +1,7 @@
"""Definitions for DSMR Reader sensors added to MQTT."""
+from homeassistant.const import VOLUME_CUBIC_METERS
+
def dsmr_transform(value):
"""Transform DSMR version value to right format."""
@@ -79,7 +81,7 @@ DEFINITIONS = {
"dsmr/reading/extra_device_delivered": {
"name": "Gas meter usage",
"icon": "mdi:fire",
- "unit": "m3",
+ "unit": VOLUME_CUBIC_METERS,
},
"dsmr/reading/phase_voltage_l1": {
"name": "Current voltage L1",
@@ -99,12 +101,12 @@ DEFINITIONS = {
"dsmr/consumption/gas/delivered": {
"name": "Gas usage",
"icon": "mdi:fire",
- "unit": "m3",
+ "unit": VOLUME_CUBIC_METERS,
},
"dsmr/consumption/gas/currently_delivered": {
"name": "Current gas usage",
"icon": "mdi:fire",
- "unit": "m3",
+ "unit": VOLUME_CUBIC_METERS,
},
"dsmr/consumption/gas/read_at": {
"name": "Gas meter read",
@@ -159,7 +161,7 @@ DEFINITIONS = {
"dsmr/day-consumption/gas": {
"name": "Gas usage",
"icon": "mdi:counter",
- "unit": "m3",
+ "unit": VOLUME_CUBIC_METERS,
},
"dsmr/day-consumption/gas_cost": {
"name": "Gas cost",
diff --git a/homeassistant/components/dte_energy_bridge/sensor.py b/homeassistant/components/dte_energy_bridge/sensor.py
index aa822da0d6a..826f9cf5acb 100644
--- a/homeassistant/components/dte_energy_bridge/sensor.py
+++ b/homeassistant/components/dte_energy_bridge/sensor.py
@@ -47,11 +47,9 @@ class DteEnergyBridgeSensor(Entity):
self._version = version
if self._version == 1:
- url_template = "http://{}/instantaneousdemand"
+ self._url = f"http://{ip_address}/instantaneousdemand"
elif self._version == 2:
- url_template = "http://{}:8888/zigbee/se/instantaneousdemand"
-
- self._url = url_template.format(ip_address)
+ self._url = f"http://{ip_address}:8888/zigbee/se/instantaneousdemand"
self._name = name
self._unit_of_measurement = "kW"
diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py
index a5fe8fd6b30..5de0b62a4a9 100644
--- a/homeassistant/components/dublin_bus_transport/sensor.py
+++ b/homeassistant/components/dublin_bus_transport/sensor.py
@@ -3,9 +3,6 @@ Support for Dublin RTPI information from data.dublinked.ie.
For more info on the API see :
https://data.gov.ie/dataset/real-time-passenger-information-rtpi-for-dublin-bus-bus-eireann-luas-and-irish-rail/resource/4b9f2c4f-6bf5-4958-a43a-f12dab04cf61
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/sensor.dublin_public_transport/
"""
from datetime import datetime, timedelta
import logging
@@ -14,7 +11,7 @@ import requests
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
import homeassistant.util.dt as dt_util
@@ -112,7 +109,7 @@ class DublinPublicTransportSensor(Entity):
@property
def unit_of_measurement(self):
"""Return the unit this state is expressed in."""
- return "min"
+ return TIME_MINUTES
@property
def icon(self):
diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py
index 695b839d18c..966ec407ce8 100644
--- a/homeassistant/components/dwd_weather_warnings/sensor.py
+++ b/homeassistant/components/dwd_weather_warnings/sensor.py
@@ -1,9 +1,6 @@
"""
Support for getting statistical data from a DWD Weather Warnings.
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/sensor.dwd_weather_warnings/
-
Data is fetched from DWD:
https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html
@@ -178,12 +175,7 @@ class DwdWeatherWarningsAPI:
def __init__(self, region_name):
"""Initialize the data object."""
- resource = "{}{}{}?{}".format(
- "https://",
- "www.dwd.de",
- "/DWD/warnungen/warnapp_landkreise/json/warnings.json",
- "jsonp=loadWarnings",
- )
+ resource = "https://www.dwd.de/DWD/warnungen/warnapp_landkreise/json/warnings.json?jsonp=loadWarnings"
# a User-Agent is necessary for this rest api endpoint (#29496)
headers = {"User-Agent": HA_USER_AGENT}
diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py
index f4fc65b8261..e27bdfbb142 100755
--- a/homeassistant/components/dynalite/__init__.py
+++ b/homeassistant/components/dynalite/__init__.py
@@ -1,4 +1,7 @@
"""Support for the Dynalite networks."""
+
+import asyncio
+
import voluptuous as vol
from homeassistant import config_entries
@@ -10,18 +13,26 @@ from homeassistant.helpers import config_validation as cv
from .bridge import DynaliteBridge
from .const import (
CONF_ACTIVE,
+ CONF_ACTIVE_INIT,
+ CONF_ACTIVE_OFF,
+ CONF_ACTIVE_ON,
CONF_AREA,
CONF_AUTO_DISCOVER,
CONF_BRIDGES,
CONF_CHANNEL,
+ CONF_CHANNEL_TYPE,
CONF_DEFAULT,
CONF_FADE,
CONF_NAME,
+ CONF_NO_DEFAULT,
CONF_POLLTIMER,
CONF_PORT,
+ CONF_PRESET,
+ DEFAULT_CHANNEL_TYPE,
DEFAULT_NAME,
DEFAULT_PORT,
DOMAIN,
+ ENTITY_PLATFORMS,
LOGGER,
)
@@ -35,16 +46,31 @@ def num_string(value):
CHANNEL_DATA_SCHEMA = vol.Schema(
- {vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_FADE): vol.Coerce(float)}
+ {
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_FADE): vol.Coerce(float),
+ vol.Optional(CONF_CHANNEL_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any(
+ "light", "switch"
+ ),
+ }
)
CHANNEL_SCHEMA = vol.Schema({num_string: CHANNEL_DATA_SCHEMA})
+PRESET_DATA_SCHEMA = vol.Schema(
+ {vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_FADE): vol.Coerce(float)}
+)
+
+PRESET_SCHEMA = vol.Schema({num_string: vol.Any(PRESET_DATA_SCHEMA, None)})
+
+
AREA_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
vol.Optional(CONF_FADE): vol.Coerce(float),
+ vol.Optional(CONF_NO_DEFAULT): vol.Coerce(bool),
vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA,
+ vol.Optional(CONF_PRESET): PRESET_SCHEMA,
},
)
@@ -62,7 +88,10 @@ BRIDGE_SCHEMA = vol.Schema(
vol.Optional(CONF_POLLTIMER, default=1.0): vol.Coerce(float),
vol.Optional(CONF_AREA): AREA_SCHEMA,
vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA,
- vol.Optional(CONF_ACTIVE, default=False): vol.Coerce(bool),
+ vol.Optional(CONF_ACTIVE, default=False): vol.Any(
+ CONF_ACTIVE_ON, CONF_ACTIVE_OFF, CONF_ACTIVE_INIT, cv.boolean
+ ),
+ vol.Optional(CONF_PRESET): PRESET_SCHEMA,
}
)
@@ -108,25 +137,29 @@ async def async_setup(hass, config):
return True
+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)
+ LOGGER.debug("Reconfiguring entry finished %s", entry.data)
+
+
async def async_setup_entry(hass, entry):
"""Set up a bridge from a config entry."""
LOGGER.debug("Setting up entry %s", entry.data)
-
bridge = DynaliteBridge(hass, entry.data)
-
+ # need to do it before the listener
+ hass.data[DOMAIN][entry.entry_id] = bridge
+ entry.add_update_listener(async_entry_changed)
if not await bridge.async_setup():
LOGGER.error("Could not set up bridge for entry %s", entry.data)
- return False
-
- if not await bridge.try_connection():
- LOGGER.errot("Could not connect with entry %s", entry)
+ hass.data[DOMAIN][entry.entry_id] = None
raise ConfigEntryNotReady
-
- hass.data[DOMAIN][entry.entry_id] = bridge
-
- hass.async_create_task(
- hass.config_entries.async_forward_entry_setup(entry, "light")
- )
+ for platform in ENTITY_PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, platform)
+ )
return True
@@ -134,5 +167,9 @@ async def async_unload_entry(hass, entry):
"""Unload a config entry."""
LOGGER.debug("Unloading entry %s", entry.data)
hass.data[DOMAIN].pop(entry.entry_id)
- result = await hass.config_entries.async_forward_entry_unload(entry, "light")
- return result
+ tasks = [
+ hass.config_entries.async_forward_entry_unload(entry, platform)
+ for platform in ENTITY_PLATFORMS
+ ]
+ results = await asyncio.gather(*tasks)
+ return False not in results
diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py
index cbe08fdadb5..fa0a91bfab1 100755
--- a/homeassistant/components/dynalite/bridge.py
+++ b/homeassistant/components/dynalite/bridge.py
@@ -1,16 +1,11 @@
"""Code to handle a Dynalite bridge."""
-import asyncio
-
-from dynalite_devices_lib import DynaliteDevices
+from dynalite_devices_lib.dynalite_devices import DynaliteDevices
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from .const import CONF_ALL, CONF_HOST, LOGGER
-
-CONNECT_TIMEOUT = 30
-CONNECT_INTERVAL = 1
+from .const import CONF_ALL, CONF_HOST, ENTITY_PLATFORMS, LOGGER
class DynaliteBridge:
@@ -20,21 +15,27 @@ class DynaliteBridge:
"""Initialize the system based on host parameter."""
self.hass = hass
self.area = {}
- self.async_add_devices = None
- self.waiting_devices = []
+ self.async_add_devices = {}
+ self.waiting_devices = {}
self.host = config[CONF_HOST]
# Configure the dynalite devices
self.dynalite_devices = DynaliteDevices(
- config=config,
- newDeviceFunc=self.add_devices_when_registered,
- updateDeviceFunc=self.update_device,
+ new_device_func=self.add_devices_when_registered,
+ update_device_func=self.update_device,
)
+ self.dynalite_devices.configure(config)
async def async_setup(self):
"""Set up a Dynalite bridge."""
# Configure the dynalite devices
+ LOGGER.debug("Setting up bridge - host %s", self.host)
return await self.dynalite_devices.async_setup()
+ def reload_config(self, config):
+ """Reconfigure a bridge when config changes."""
+ LOGGER.debug("Reloading bridge - host %s, config %s", self.host, config)
+ self.dynalite_devices.configure(config)
+
def update_signal(self, device=None):
"""Create signal to use to trigger entity update."""
if device:
@@ -56,27 +57,22 @@ class DynaliteBridge:
else:
async_dispatcher_send(self.hass, self.update_signal(device))
- async def try_connection(self):
- """Try to connect to dynalite with timeout."""
- # Currently by polling. Future - will need to change the library to be proactive
- for _ in range(0, CONNECT_TIMEOUT):
- if self.dynalite_devices.available:
- return True
- await asyncio.sleep(CONNECT_INTERVAL)
- return False
-
@callback
- def register_add_devices(self, async_add_devices):
+ def register_add_devices(self, platform, async_add_devices):
"""Add an async_add_entities for a category."""
- self.async_add_devices = async_add_devices
- if self.waiting_devices:
- self.async_add_devices(self.waiting_devices)
+ self.async_add_devices[platform] = async_add_devices
+ if platform in self.waiting_devices:
+ self.async_add_devices[platform](self.waiting_devices[platform])
def add_devices_when_registered(self, devices):
"""Add the devices to HA if the add devices callback was registered, otherwise queue until it is."""
- if not devices:
- return
- if self.async_add_devices:
- self.async_add_devices(devices)
- else: # handle it later when it is registered
- self.waiting_devices.extend(devices)
+ for platform in ENTITY_PLATFORMS:
+ platform_devices = [
+ device for device in devices if device.category == platform
+ ]
+ if platform in self.async_add_devices:
+ self.async_add_devices[platform](platform_devices)
+ else: # handle it later when it is registered
+ if platform not in self.waiting_devices:
+ self.waiting_devices[platform] = []
+ self.waiting_devices[platform].extend(platform_devices)
diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py
index aac42172181..ca95c0754a6 100755
--- a/homeassistant/components/dynalite/config_flow.py
+++ b/homeassistant/components/dynalite/config_flow.py
@@ -3,7 +3,7 @@ from homeassistant import config_entries
from homeassistant.const import CONF_HOST
from .bridge import DynaliteBridge
-from .const import DOMAIN, LOGGER # pylint: disable=unused-import
+from .const import DOMAIN, LOGGER
class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
@@ -12,8 +12,6 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
- # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
-
def __init__(self):
"""Initialize the Dynalite flow."""
self.host = None
@@ -22,14 +20,15 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Import a new bridge as a config entry."""
LOGGER.debug("Starting async_step_import - %s", import_info)
host = import_info[CONF_HOST]
- await self.async_set_unique_id(host)
- self._abort_if_unique_id_configured(import_info)
+ for entry in self.hass.config_entries.async_entries(DOMAIN):
+ if entry.data[CONF_HOST] == host:
+ if entry.data != import_info:
+ self.hass.config_entries.async_update_entry(entry, data=import_info)
+ return self.async_abort(reason="already_configured")
# New entry
bridge = DynaliteBridge(self.hass, import_info)
if not await bridge.async_setup():
LOGGER.error("Unable to setup bridge - import info=%s", import_info)
- return self.async_abort(reason="bridge_setup_failed")
- if not await bridge.try_connection():
return self.async_abort(reason="no_connection")
LOGGER.debug("Creating entry for the bridge - %s", import_info)
return self.async_create_entry(title=host, data=import_info)
diff --git a/homeassistant/components/dynalite/const.py b/homeassistant/components/dynalite/const.py
index f7795554465..267b5727b83 100755
--- a/homeassistant/components/dynalite/const.py
+++ b/homeassistant/components/dynalite/const.py
@@ -4,18 +4,28 @@ import logging
LOGGER = logging.getLogger(__package__)
DOMAIN = "dynalite"
+ENTITY_PLATFORMS = ["light", "switch"]
+
+
CONF_ACTIVE = "active"
+CONF_ACTIVE_INIT = "init"
+CONF_ACTIVE_OFF = "off"
+CONF_ACTIVE_ON = "on"
CONF_ALL = "ALL"
CONF_AREA = "area"
CONF_AUTO_DISCOVER = "autodiscover"
CONF_BRIDGES = "bridges"
CONF_CHANNEL = "channel"
+CONF_CHANNEL_TYPE = "type"
CONF_DEFAULT = "default"
CONF_FADE = "fade"
CONF_HOST = "host"
CONF_NAME = "name"
+CONF_NO_DEFAULT = "nodefault"
CONF_POLLTIMER = "polltimer"
CONF_PORT = "port"
+CONF_PRESET = "preset"
+DEFAULT_CHANNEL_TYPE = "light"
DEFAULT_NAME = "dynalite"
DEFAULT_PORT = 12345
diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py
new file mode 100755
index 00000000000..8bb1ab2dc42
--- /dev/null
+++ b/homeassistant/components/dynalite/dynalitebase.py
@@ -0,0 +1,85 @@
+"""Support for the Dynalite devices as entities."""
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from .const import DOMAIN, LOGGER
+
+
+def async_setup_entry_base(
+ hass, config_entry, async_add_entities, platform, entity_from_device
+):
+ """Record the async_add_entities function to add them later when received from Dynalite."""
+ LOGGER.debug("Setting up %s entry = %s", platform, config_entry.data)
+ bridge = hass.data[DOMAIN][config_entry.entry_id]
+
+ @callback
+ def async_add_entities_platform(devices):
+ # assumes it is called with a single platform
+ added_entities = []
+ for device in devices:
+ if device.category == platform:
+ added_entities.append(entity_from_device(device, bridge))
+ if added_entities:
+ async_add_entities(added_entities)
+
+ bridge.register_add_devices(platform, async_add_entities_platform)
+
+
+class DynaliteBase(Entity):
+ """Base class for the Dynalite entities."""
+
+ def __init__(self, device, bridge):
+ """Initialize the base class."""
+ self._device = device
+ self._bridge = bridge
+ self._unsub_dispatchers = []
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._device.name
+
+ @property
+ def unique_id(self):
+ """Return the unique ID of the entity."""
+ return self._device.unique_id
+
+ @property
+ def available(self):
+ """Return if entity is available."""
+ return self._device.available
+
+ @property
+ def device_info(self):
+ """Device info for this entity."""
+ return {
+ "identifiers": {(DOMAIN, self._device.unique_id)},
+ "name": self.name,
+ "manufacturer": "Dynalite",
+ }
+
+ async def async_added_to_hass(self):
+ """Added to hass so need to register to dispatch."""
+ # register for device specific update
+ self._unsub_dispatchers.append(
+ async_dispatcher_connect(
+ self.hass,
+ self._bridge.update_signal(self._device),
+ self.async_schedule_update_ha_state,
+ )
+ )
+ # register for wide update
+ self._unsub_dispatchers.append(
+ async_dispatcher_connect(
+ self.hass,
+ self._bridge.update_signal(),
+ self.async_schedule_update_ha_state,
+ )
+ )
+
+ async def async_will_remove_from_hass(self):
+ """Unregister signal dispatch listeners when being removed."""
+ for unsub in self._unsub_dispatchers:
+ unsub()
+ self._unsub_dispatchers = []
diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py
index 652a6178705..a5b7139803c 100755
--- a/homeassistant/components/dynalite/light.py
+++ b/homeassistant/components/dynalite/light.py
@@ -1,64 +1,20 @@
"""Support for Dynalite channels as lights."""
from homeassistant.components.light import SUPPORT_BRIGHTNESS, Light
-from homeassistant.core import callback
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from .const import DOMAIN, LOGGER
+from .dynalitebase import DynaliteBase, async_setup_entry_base
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Record the async_add_entities function to add them later when received from Dynalite."""
- LOGGER.debug("Setting up light entry = %s", config_entry.data)
- bridge = hass.data[DOMAIN][config_entry.entry_id]
- @callback
- def async_add_lights(devices):
- added_lights = []
- for device in devices:
- if device.category == "light":
- added_lights.append(DynaliteLight(device, bridge))
- if added_lights:
- async_add_entities(added_lights)
-
- bridge.register_add_devices(async_add_lights)
+ async_setup_entry_base(
+ hass, config_entry, async_add_entities, "light", DynaliteLight
+ )
-class DynaliteLight(Light):
+class DynaliteLight(DynaliteBase, Light):
"""Representation of a Dynalite Channel as a Home Assistant Light."""
- def __init__(self, device, bridge):
- """Initialize the base class."""
- self._device = device
- self._bridge = bridge
-
- @property
- def name(self):
- """Return the name of the entity."""
- return self._device.name
-
- @property
- def unique_id(self):
- """Return the unique ID of the entity."""
- return self._device.unique_id
-
- @property
- def available(self):
- """Return if entity is available."""
- return self._device.available
-
- async def async_update(self):
- """Update the entity."""
- return
-
- @property
- def device_info(self):
- """Device info for this entity."""
- return {
- "identifiers": {(DOMAIN, self.unique_id)},
- "name": self.name,
- "manufacturer": "Dynalite",
- }
-
@property
def brightness(self):
"""Return the brightness of this light between 0..255."""
@@ -81,16 +37,3 @@ class DynaliteLight(Light):
def supported_features(self):
"""Flag supported features."""
return SUPPORT_BRIGHTNESS
-
- async def async_added_to_hass(self):
- """Added to hass so need to register to dispatch."""
- # register for device specific update
- async_dispatcher_connect(
- self.hass,
- self._bridge.update_signal(self._device),
- self.async_schedule_update_ha_state,
- )
- # register for wide update
- async_dispatcher_connect(
- self.hass, self._bridge.update_signal(), self.async_schedule_update_ha_state
- )
diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json
index 95667733d38..d6351db17b2 100755
--- a/homeassistant/components/dynalite/manifest.json
+++ b/homeassistant/components/dynalite/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/dynalite",
"dependencies": [],
"codeowners": ["@ziv1234"],
- "requirements": ["dynalite_devices==0.1.22"]
+ "requirements": ["dynalite_devices==0.1.32"]
}
diff --git a/homeassistant/components/dynalite/switch.py b/homeassistant/components/dynalite/switch.py
new file mode 100755
index 00000000000..84be74cee36
--- /dev/null
+++ b/homeassistant/components/dynalite/switch.py
@@ -0,0 +1,29 @@
+"""Support for the Dynalite channels and presets as switches."""
+from homeassistant.components.switch import SwitchDevice
+
+from .dynalitebase import DynaliteBase, async_setup_entry_base
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Record the async_add_entities function to add them later when received from Dynalite."""
+
+ async_setup_entry_base(
+ hass, config_entry, async_add_entities, "switch", DynaliteSwitch
+ )
+
+
+class DynaliteSwitch(DynaliteBase, SwitchDevice):
+ """Representation of a Dynalite Channel as a Home Assistant Switch."""
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._device.is_on
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the switch on."""
+ await self._device.async_turn_on()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the switch off."""
+ await self._device.async_turn_off()
diff --git a/homeassistant/components/dyson/climate.py b/homeassistant/components/dyson/climate.py
index df97358d550..f4e23b01622 100644
--- a/homeassistant/components/dyson/climate.py
+++ b/homeassistant/components/dyson/climate.py
@@ -89,7 +89,7 @@ class DysonPureHotCoolLinkDevice(ClimateDevice):
if self._device.environmental_state:
temperature_kelvin = self._device.environmental_state.temperature
if temperature_kelvin != 0:
- self._current_temp = float("{0:.1f}".format(temperature_kelvin - 273))
+ self._current_temp = float(f"{(temperature_kelvin - 273):.1f}")
return self._current_temp
@property
diff --git a/homeassistant/components/dyson/fan.py b/homeassistant/components/dyson/fan.py
index 2d41e6b828a..8613ab3e7af 100644
--- a/homeassistant/components/dyson/fan.py
+++ b/homeassistant/components/dyson/fan.py
@@ -1,8 +1,4 @@
-"""Support for Dyson Pure Cool link fan.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/fan.dyson/
-"""
+"""Support for Dyson Pure Cool link fan."""
import logging
from libpurecool.const import FanMode, FanSpeed, NightMode, Oscillation
@@ -157,10 +153,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
)
hass.services.register(
- DYSON_DOMAIN,
- SERVICE_SET_AUTO_MODE,
- service_handle,
- schema=SET_AUTO_MODE_SCHEMA,
+ DYSON_DOMAIN, SERVICE_SET_AUTO_MODE, service_handle, schema=SET_AUTO_MODE_SCHEMA
)
if has_purecool_devices:
hass.services.register(
@@ -223,7 +216,7 @@ class DysonPureCoolLinkDevice(FanEntity):
if speed == FanSpeed.FAN_SPEED_AUTO.value:
self._device.set_configuration(fan_mode=FanMode.AUTO)
else:
- fan_speed = FanSpeed("{0:04d}".format(int(speed)))
+ fan_speed = FanSpeed(f"{int(speed):04d}")
self._device.set_configuration(fan_mode=FanMode.FAN, fan_speed=fan_speed)
def turn_on(self, speed: str = None, **kwargs) -> None:
@@ -233,7 +226,7 @@ class DysonPureCoolLinkDevice(FanEntity):
if speed == FanSpeed.FAN_SPEED_AUTO.value:
self._device.set_configuration(fan_mode=FanMode.AUTO)
else:
- fan_speed = FanSpeed("{0:04d}".format(int(speed)))
+ fan_speed = FanSpeed(f"{int(speed):04d}")
self._device.set_configuration(
fan_mode=FanMode.FAN, fan_speed=fan_speed
)
@@ -393,7 +386,7 @@ class DysonPureCoolDevice(FanEntity):
"""Set the exact speed of the purecool fan."""
_LOGGER.debug("Set exact speed for fan %s", self.name)
- fan_speed = FanSpeed("{0:04d}".format(int(speed)))
+ fan_speed = FanSpeed(f"{int(speed):04d}")
self._device.set_fan_speed(fan_speed)
def oscillate(self, oscillating: bool) -> None:
diff --git a/homeassistant/components/dyson/sensor.py b/homeassistant/components/dyson/sensor.py
index 2fdd3cd6c1f..55f2ff69314 100644
--- a/homeassistant/components/dyson/sensor.py
+++ b/homeassistant/components/dyson/sensor.py
@@ -4,7 +4,7 @@ import logging
from libpurecool.dyson_pure_cool import DysonPureCool
from libpurecool.dyson_pure_cool_link import DysonPureCoolLink
-from homeassistant.const import STATE_OFF, TEMP_CELSIUS
+from homeassistant.const import STATE_OFF, TEMP_CELSIUS, TIME_HOURS, UNIT_PERCENTAGE
from homeassistant.helpers.entity import Entity
from . import DYSON_DEVICES
@@ -12,8 +12,8 @@ from . import DYSON_DEVICES
SENSOR_UNITS = {
"air_quality": None,
"dust": None,
- "filter_life": "hours",
- "humidity": "%",
+ "filter_life": TIME_HOURS,
+ "humidity": UNIT_PERCENTAGE,
}
SENSOR_ICONS = {
@@ -43,9 +43,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
device_ids = [device.unique_id for device in hass.data[DYSON_SENSOR_DEVICES]]
for device in hass.data[DYSON_DEVICES]:
if isinstance(device, DysonPureCool):
- if "{}-{}".format(device.serial, "temperature") not in device_ids:
+ if f"{device.serial}-temperature" not in device_ids:
devices.append(DysonTemperatureSensor(device, unit))
- if "{}-{}".format(device.serial, "humidity") not in device_ids:
+ if f"{device.serial}-humidity" not in device_ids:
devices.append(DysonHumiditySensor(device))
elif isinstance(device, DysonPureCoolLink):
devices.append(DysonFilterLifeSensor(device))
@@ -173,8 +173,8 @@ class DysonTemperatureSensor(DysonSensor):
if temperature_kelvin == 0:
return STATE_OFF
if self._unit == TEMP_CELSIUS:
- return float("{0:.1f}".format(temperature_kelvin - 273.15))
- return float("{0:.1f}".format(temperature_kelvin * 9 / 5 - 459.67))
+ return float(f"{(temperature_kelvin - 273.15):.1f}")
+ return float(f"{(temperature_kelvin * 9 / 5 - 459.67):.1f}")
return None
@property
diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py
index 54355ed3bb8..dc150109cf7 100644
--- a/homeassistant/components/ebox/sensor.py
+++ b/homeassistant/components/ebox/sensor.py
@@ -2,9 +2,6 @@
Support for EBox.
Get data from 'My Usage Page' page: https://client.ebox.ca/myusage
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/sensor.ebox/
"""
from datetime import timedelta
import logging
@@ -20,6 +17,8 @@ from homeassistant.const import (
CONF_PASSWORD,
CONF_USERNAME,
DATA_GIGABITS,
+ TIME_DAYS,
+ UNIT_PERCENTAGE,
)
from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.config_validation as cv
@@ -29,8 +28,6 @@ from homeassistant.util import Throttle
_LOGGER = logging.getLogger(__name__)
PRICE = "CAD"
-DAYS = "days"
-PERCENT = "%"
DEFAULT_NAME = "EBox"
@@ -39,10 +36,10 @@ SCAN_INTERVAL = timedelta(minutes=15)
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
SENSOR_TYPES = {
- "usage": ["Usage", PERCENT, "mdi:percent"],
+ "usage": ["Usage", UNIT_PERCENTAGE, "mdi:percent"],
"balance": ["Balance", PRICE, "mdi:square-inc-cash"],
"limit": ["Data limit", DATA_GIGABITS, "mdi:download"],
- "days_left": ["Days left", DAYS, "mdi:calendar-today"],
+ "days_left": ["Days left", TIME_DAYS, "mdi:calendar-today"],
"before_offpeak_download": [
"Download before offpeak",
DATA_GIGABITS,
diff --git a/homeassistant/components/ebusd/const.py b/homeassistant/components/ebusd/const.py
index ec097a153c9..10ed0b68e87 100644
--- a/homeassistant/components/ebusd/const.py
+++ b/homeassistant/components/ebusd/const.py
@@ -1,8 +1,12 @@
"""Constants for ebus component."""
-from homeassistant.const import ENERGY_KILO_WATT_HOUR, PRESSURE_BAR, TEMP_CELSIUS
+from homeassistant.const import (
+ ENERGY_KILO_WATT_HOUR,
+ PRESSURE_BAR,
+ TEMP_CELSIUS,
+ TIME_SECONDS,
+)
DOMAIN = "ebusd"
-TIME_SECONDS = "seconds"
# SensorTypes from ebusdpy module :
# 0='decimal', 1='time-schedule', 2='switch', 3='string', 4='value;status'
diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json
index 8e21b9931cd..9f6b861c8fb 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.1"],
+ "requirements": ["python-ecobee-api==0.2.2"],
"codeowners": ["@marthoc"]
}
diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py
index c2c34d148e3..e510cc976a6 100644
--- a/homeassistant/components/ecobee/sensor.py
+++ b/homeassistant/components/ecobee/sensor.py
@@ -5,6 +5,7 @@ from homeassistant.const import (
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers.entity import Entity
@@ -12,7 +13,7 @@ from .const import _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER
SENSOR_TYPES = {
"temperature": ["Temperature", TEMP_FAHRENHEIT],
- "humidity": ["Humidity", "%"],
+ "humidity": ["Humidity", UNIT_PERCENTAGE],
}
@@ -37,7 +38,7 @@ class EcobeeSensor(Entity):
def __init__(self, data, sensor_name, sensor_type, sensor_index):
"""Initialize the sensor."""
self.data = data
- self._name = "{} {}".format(sensor_name, SENSOR_TYPES[sensor_type][0])
+ self._name = f"{sensor_name} {SENSOR_TYPES[sensor_type][0]}"
self.sensor_name = sensor_name
self.type = sensor_type
self.index = sensor_index
diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py
index a74fdaa21ba..806c0b41285 100644
--- a/homeassistant/components/ecovacs/vacuum.py
+++ b/homeassistant/components/ecovacs/vacuum.py
@@ -56,10 +56,10 @@ class EcovacsVacuum(VacuumDevice):
self.device = device
self.device.connect_and_wait_until_ready()
if self.device.vacuum.get("nick", None) is not None:
- self._name = "{}".format(self.device.vacuum["nick"])
+ self._name = str(self.device.vacuum["nick"])
else:
# In case there is no nickname defined, use the device id
- self._name = "{}".format(self.device.vacuum["did"])
+ self._name = str(format(self.device.vacuum["did"]))
self._fan_speed = None
self._error = None
diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py
index 3b5aa95701f..1d6ff61bf59 100644
--- a/homeassistant/components/eddystone_temperature/sensor.py
+++ b/homeassistant/components/eddystone_temperature/sensor.py
@@ -3,9 +3,6 @@ Read temperature information from Eddystone beacons.
Your beacons must be configured to transmit UID (for identification) and TLM
(for temperature) frames.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/sensor.eddystone_temperature/
"""
import logging
diff --git a/homeassistant/components/edl21/__init__.py b/homeassistant/components/edl21/__init__.py
new file mode 100644
index 00000000000..f1cd5984744
--- /dev/null
+++ b/homeassistant/components/edl21/__init__.py
@@ -0,0 +1 @@
+"""The edl21 component."""
diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json
new file mode 100644
index 00000000000..313ac2c262e
--- /dev/null
+++ b/homeassistant/components/edl21/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "edl21",
+ "name": "EDL21",
+ "documentation": "https://www.home-assistant.io/integrations/edl21",
+ "requirements": [
+ "pysml==0.0.2"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@mtdcr"
+ ]
+}
diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py
new file mode 100644
index 00000000000..a4e0ca734fa
--- /dev/null
+++ b/homeassistant/components/edl21/sensor.py
@@ -0,0 +1,196 @@
+"""Support for EDL21 Smart Meters."""
+
+from datetime import timedelta
+import logging
+
+from sml import SmlGetListResponse
+from sml.asyncio import SmlProtocol
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect,
+ async_dispatcher_send,
+)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.typing import Optional
+from homeassistant.util.dt import utcnow
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = "edl21"
+CONF_SERIAL_PORT = "serial_port"
+ICON_POWER = "mdi:flash"
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
+SIGNAL_EDL21_TELEGRAM = "edl21_telegram"
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_SERIAL_PORT): cv.string})
+
+
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+ """Set up the EDL21 sensor."""
+ hass.data[DOMAIN] = EDL21(hass, config, async_add_entities)
+ await hass.data[DOMAIN].connect()
+
+
+class EDL21:
+ """EDL21 handles telegrams sent by a compatible smart meter."""
+
+ # OBIS format: A-B:C.D.E*F
+ _OBIS_NAMES = {
+ # A=1: Electricity
+ # C=0: General purpose objects
+ "1-0:0.0.9*255": "Electricity ID",
+ # C=1: Active power +
+ # D=8: Time integral 1
+ # E=0: Total
+ "1-0:1.8.0*255": "Positive active energy total",
+ # E=1: Rate 1
+ "1-0:1.8.1*255": "Positive active energy in tariff T1",
+ # E=2: Rate 2
+ "1-0:1.8.2*255": "Positive active energy in tariff T2",
+ # D=17: Time integral 7
+ # E=0: Total
+ "1-0:1.17.0*255": "Last signed positive active energy total",
+ # C=15: Active power absolute
+ # D=7: Instantaneous value
+ # E=0: Total
+ "1-0:15.7.0*255": "Absolute active instantaneous power",
+ # C=16: Active power sum
+ # D=7: Instantaneous value
+ # E=0: Total
+ "1-0:16.7.0*255": "Sum active instantaneous power",
+ }
+ _OBIS_BLACKLIST = {
+ # A=129: Manufacturer specific
+ "129-129:199.130.3*255", # Iskraemeco: Manufacturer
+ "129-129:199.130.5*255", # Iskraemeco: Public Key
+ }
+
+ def __init__(self, hass, config, async_add_entities) -> None:
+ """Initialize an EDL21 object."""
+ self._registered_obis = set()
+ self._hass = hass
+ self._async_add_entities = async_add_entities
+ self._proto = SmlProtocol(config[CONF_SERIAL_PORT])
+ self._proto.add_listener(self.event, ["SmlGetListResponse"])
+
+ async def connect(self):
+ """Connect to an EDL21 reader."""
+ await self._proto.connect(self._hass.loop)
+
+ def event(self, message_body) -> None:
+ """Handle events from pysml."""
+ assert isinstance(message_body, SmlGetListResponse)
+
+ new_entities = []
+ for telegram in message_body.get("valList", []):
+ obis = telegram.get("objName")
+ if not obis:
+ continue
+
+ if obis in self._registered_obis:
+ async_dispatcher_send(self._hass, SIGNAL_EDL21_TELEGRAM, telegram)
+ else:
+ name = self._OBIS_NAMES.get(obis)
+ if name:
+ new_entities.append(EDL21Entity(obis, name, telegram))
+ self._registered_obis.add(obis)
+ elif obis not in self._OBIS_BLACKLIST:
+ _LOGGER.warning(
+ "Unhandled sensor %s detected. Please report at "
+ 'https://github.com/home-assistant/home-assistant/issues?q=is%%3Aissue+label%%3A"integration%%3A+edl21"+',
+ obis,
+ )
+ self._OBIS_BLACKLIST.add(obis)
+
+ if new_entities:
+ self._async_add_entities(new_entities, update_before_add=True)
+
+
+class EDL21Entity(Entity):
+ """Entity reading values from EDL21 telegram."""
+
+ def __init__(self, obis, name, telegram):
+ """Initialize an EDL21Entity."""
+ self._obis = obis
+ self._name = name
+ self._telegram = telegram
+ self._min_time = MIN_TIME_BETWEEN_UPDATES
+ self._last_update = utcnow()
+ self._state_attrs = {
+ "status": "status",
+ "valTime": "val_time",
+ "scaler": "scaler",
+ "valueSignature": "value_signature",
+ }
+ self._async_remove_dispatcher = None
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+
+ @callback
+ def handle_telegram(telegram):
+ """Update attributes from last received telegram for this object."""
+ if self._obis != telegram.get("objName"):
+ return
+ if self._telegram == telegram:
+ return
+
+ now = utcnow()
+ if now - self._last_update < self._min_time:
+ return
+
+ self._telegram = telegram
+ self._last_update = now
+ self.async_write_ha_state()
+
+ self._async_remove_dispatcher = async_dispatcher_connect(
+ self.hass, SIGNAL_EDL21_TELEGRAM, handle_telegram
+ )
+
+ async def async_will_remove_from_hass(self):
+ """Run when entity will be removed from hass."""
+ if self._async_remove_dispatcher:
+ self._async_remove_dispatcher()
+
+ @property
+ def should_poll(self) -> bool:
+ """Do not poll."""
+ return False
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return self._obis
+
+ @property
+ def name(self) -> Optional[str]:
+ """Return a name."""
+ return self._name
+
+ @property
+ def state(self) -> str:
+ """Return the value of the last received telegram."""
+ return self._telegram.get("value")
+
+ @property
+ def device_state_attributes(self):
+ """Enumerate supported attributes."""
+ return {
+ self._state_attrs[k]: v
+ for k, v in self._telegram.items()
+ if k in self._state_attrs
+ }
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._telegram.get("unit")
+
+ @property
+ def icon(self):
+ """Return an icon."""
+ return ICON_POWER
diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py
index 3be962fea2f..8c16317beda 100644
--- a/homeassistant/components/efergy/sensor.py
+++ b/homeassistant/components/efergy/sensor.py
@@ -63,9 +63,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
dev = []
for variable in config[CONF_MONITORED_VARIABLES]:
if variable[CONF_SENSOR_TYPE] == CONF_CURRENT_VALUES:
- url_string = "{}getCurrentValuesSummary?token={}".format(
- _RESOURCE, app_token
- )
+ url_string = f"{_RESOURCE}getCurrentValuesSummary?token={app_token}"
response = requests.get(url_string, timeout=10)
for sensor in response.json():
sid = sensor["sid"]
@@ -136,9 +134,7 @@ class EfergySensor(Entity):
response = requests.get(url_string, timeout=10)
self._state = response.json()["reading"]
elif self.type == "amount":
- url_string = "{}getEnergy?token={}&offset={}&period={}".format(
- _RESOURCE, self.app_token, self.utc_offset, self.period
- )
+ url_string = f"{_RESOURCE}getEnergy?token={self.app_token}&offset={self.utc_offset}&period={self.period}"
response = requests.get(url_string, timeout=10)
self._state = response.json()["sum"]
elif self.type == "budget":
@@ -146,14 +142,12 @@ class EfergySensor(Entity):
response = requests.get(url_string, timeout=10)
self._state = response.json()["status"]
elif self.type == "cost":
- url_string = "{}getCost?token={}&offset={}&period={}".format(
- _RESOURCE, self.app_token, self.utc_offset, self.period
- )
+ url_string = f"{_RESOURCE}getCost?token={self.app_token}&offset={self.utc_offset}&period={self.period}"
response = requests.get(url_string, timeout=10)
self._state = response.json()["sum"]
elif self.type == "current_values":
- url_string = "{}getCurrentValuesSummary?token={}".format(
- _RESOURCE, self.app_token
+ url_string = (
+ f"{_RESOURCE}getCurrentValuesSummary?token={self.app_token}"
)
response = requests.get(url_string, timeout=10)
for sensor in response.json():
diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py
index f0fc4b5d1d6..dcee52db592 100644
--- a/homeassistant/components/eight_sleep/sensor.py
+++ b/homeassistant/components/eight_sleep/sensor.py
@@ -1,6 +1,8 @@
"""Support for Eight Sleep sensors."""
import logging
+from homeassistant.const import UNIT_PERCENTAGE
+
from . import (
CONF_SENSORS,
DATA_EIGHT,
@@ -18,9 +20,9 @@ ATTR_AVG_RESP_RATE = "Average Respiratory Rate"
ATTR_HEART_RATE = "Heart Rate"
ATTR_AVG_HEART_RATE = "Average Heart Rate"
ATTR_SLEEP_DUR = "Time Slept"
-ATTR_LIGHT_PERC = "Light Sleep %"
-ATTR_DEEP_PERC = "Deep Sleep %"
-ATTR_REM_PERC = "REM Sleep %"
+ATTR_LIGHT_PERC = f"Light Sleep {UNIT_PERCENTAGE}"
+ATTR_DEEP_PERC = f"Deep Sleep {UNIT_PERCENTAGE}"
+ATTR_REM_PERC = f"REM Sleep {UNIT_PERCENTAGE}"
ATTR_TNT = "Tosses & Turns"
ATTR_SLEEP_STAGE = "Sleep Stage"
ATTR_TARGET_HEAT = "Target Heating Level"
@@ -100,7 +102,7 @@ class EightHeatSensor(EightSleepHeatEntity):
@property
def unit_of_measurement(self):
"""Return the unit the value is expressed in."""
- return "%"
+ return UNIT_PERCENTAGE
async def async_update(self):
"""Retrieve latest state."""
diff --git a/homeassistant/components/elgato/.translations/lv.json b/homeassistant/components/elgato/.translations/lv.json
new file mode 100644
index 00000000000..5babfa037ac
--- /dev/null
+++ b/homeassistant/components/elgato/.translations/lv.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "port": "Porta numurs"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py
index 67b84c4f3bf..2acb8030cf1 100644
--- a/homeassistant/components/elkm1/__init__.py
+++ b/homeassistant/components/elkm1/__init__.py
@@ -257,9 +257,7 @@ class ElkEntity(Entity):
uid_start = f"elkm1m_{self._prefix}"
else:
uid_start = "elkm1"
- self._unique_id = "{uid_start}_{name}".format(
- uid_start=uid_start, name=self._element.default_name("_")
- ).lower()
+ self._unique_id = f"{uid_start}_{self._element.default_name('_')}".lower()
@property
def name(self):
diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py
index 3ed5356f4de..df29e1cda7e 100644
--- a/homeassistant/components/elkm1/sensor.py
+++ b/homeassistant/components/elkm1/sensor.py
@@ -178,7 +178,7 @@ class ElkZone(ElkSensor):
ZoneType.PHONE_KEY.value: "phone-classic",
ZoneType.INTERCOM_KEY.value: "deskphone",
}
- return "mdi:{}".format(zone_icons.get(self._element.definition, "alarm-bell"))
+ return f"mdi:{zone_icons.get(self._element.definition, 'alarm-bell')}"
@property
def device_state_attributes(self):
diff --git a/homeassistant/components/elv/switch.py b/homeassistant/components/elv/switch.py
index a77d21cf173..d867d286f50 100644
--- a/homeassistant/components/elv/switch.py
+++ b/homeassistant/components/elv/switch.py
@@ -81,12 +81,12 @@ class SmartPlugSwitch(SwitchDevice):
def update(self):
"""Update the PCA switch's state."""
try:
- self._emeter_params[ATTR_CURRENT_POWER_W] = "{:.1f}".format(
- self._pca.get_current_power(self._device_id)
- )
- self._emeter_params[ATTR_TOTAL_ENERGY_KWH] = "{:.2f}".format(
- self._pca.get_total_consumption(self._device_id)
- )
+ self._emeter_params[
+ ATTR_CURRENT_POWER_W
+ ] = f"{self._pca.get_current_power(self._device_id):.1f}"
+ self._emeter_params[
+ ATTR_TOTAL_ENERGY_KWH
+ ] = f"{self._pca.get_total_consumption(self._device_id):.2f}"
self._available = True
self._state = self._pca.get_state(self._device_id)
diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py
index b4b05fd6c12..56d68cee6b5 100644
--- a/homeassistant/components/emby/media_player.py
+++ b/homeassistant/components/emby/media_player.py
@@ -190,9 +190,7 @@ class EmbyDevice(MediaPlayerDevice):
@property
def name(self):
"""Return the name of the device."""
- return (
- f"Emby - {self.device.client} - {self.device.name}" or DEVICE_DEFAULT_NAME
- )
+ return f"Emby {self.device.name}" or DEVICE_DEFAULT_NAME
@property
def should_poll(self):
diff --git a/homeassistant/components/emoncms/manifest.json b/homeassistant/components/emoncms/manifest.json
index 83833d4f79b..b9c012d6e73 100644
--- a/homeassistant/components/emoncms/manifest.json
+++ b/homeassistant/components/emoncms/manifest.json
@@ -4,5 +4,5 @@
"documentation": "https://www.home-assistant.io/integrations/emoncms",
"requirements": [],
"dependencies": [],
- "codeowners": []
+ "codeowners": ["@borpin"]
}
diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py
index f956d3a7295..c0754405840 100644
--- a/homeassistant/components/emoncms/sensor.py
+++ b/homeassistant/components/emoncms/sensor.py
@@ -37,7 +37,6 @@ CONF_SENSOR_NAMES = "sensor_names"
DECIMALS = 2
DEFAULT_UNIT = POWER_WATT
-
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
ONLY_INCL_EXCL_NONE = "only_include_exclude_or_none"
@@ -64,9 +63,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
def get_id(sensorid, feedtag, feedname, feedid, feeduserid):
"""Return unique identifier for feed / sensor."""
- return "emoncms{}_{}_{}_{}_{}".format(
- sensorid, feedtag, feedname, feedid, feeduserid
- )
+ return f"emoncms{sensorid}_{feedtag}_{feedname}_{feedid}_{feeduserid}"
def setup_platform(hass, config, add_entities, discovery_info=None):
@@ -75,7 +72,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
url = config.get(CONF_URL)
sensorid = config.get(CONF_ID)
value_template = config.get(CONF_VALUE_TEMPLATE)
- unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
+ config_unit = config.get(CONF_UNIT_OF_MEASUREMENT)
exclude_feeds = config.get(CONF_EXCLUDE_FEEDID)
include_only_feeds = config.get(CONF_ONLY_INCLUDE_FEEDID)
sensor_names = config.get(CONF_SENSOR_NAMES)
@@ -107,6 +104,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
if sensor_names is not None:
name = sensor_names.get(int(elem["id"]), None)
+ unit = elem.get("unit")
+ if unit:
+ unit_of_measurement = unit
+ else:
+ unit_of_measurement = config_unit
+
sensors.append(
EmonCmsSensor(
hass,
@@ -134,7 +137,7 @@ class EmonCmsSensor(Entity):
# ID if there's only one.
id_for_name = "" if str(sensorid) == "1" else sensorid
# Use the feed name assigned in EmonCMS or fall back to the feed ID
- feed_name = elem.get("name") or "Feed {}".format(elem["id"])
+ feed_name = elem.get("name") or f"Feed {elem['id']}"
self._name = f"EmonCMS{id_for_name} {feed_name}"
else:
self._name = name
diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py
index e9e7114074a..06a57960898 100644
--- a/homeassistant/components/emulated_hue/hue_api.py
+++ b/homeassistant/components/emulated_hue/hue_api.py
@@ -617,16 +617,7 @@ def entity_to_json(config, entity):
"""Convert an entity to its Hue bridge JSON representation."""
entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
unique_id = hashlib.md5(entity.entity_id.encode()).hexdigest()
- unique_id = "00:{}:{}:{}:{}:{}:{}:{}-{}".format(
- unique_id[0:2],
- unique_id[2:4],
- unique_id[4:6],
- unique_id[6:8],
- unique_id[8:10],
- unique_id[10:12],
- unique_id[12:14],
- unique_id[14:16],
- )
+ unique_id = f"00:{unique_id[0:2]}:{unique_id[2:4]}:{unique_id[4:6]}:{unique_id[6:8]}:{unique_id[8:10]}:{unique_id[10:12]}:{unique_id[12:14]}-{unique_id[14:16]}"
state = get_entity_state(config, entity)
@@ -686,7 +677,11 @@ def entity_to_json(config, entity):
retval["type"] = "Color temperature light"
retval["modelid"] = "HASS312"
retval["state"].update(
- {HUE_API_STATE_COLORMODE: "ct", HUE_API_STATE_CT: state[STATE_COLOR_TEMP]}
+ {
+ HUE_API_STATE_COLORMODE: "ct",
+ HUE_API_STATE_CT: state[STATE_COLOR_TEMP],
+ HUE_API_STATE_BRI: state[STATE_BRIGHTNESS],
+ }
)
elif entity_features & (
SUPPORT_BRIGHTNESS
diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py
index da9b4e23fe2..0ee336de670 100644
--- a/homeassistant/components/emulated_hue/upnp.py
+++ b/homeassistant/components/emulated_hue/upnp.py
@@ -26,16 +26,16 @@ class DescriptionXmlView(HomeAssistantView):
@core.callback
def get(self, request):
"""Handle a GET request."""
- xml_template = """
+ resp_text = f"""
1
0
-http://{0}:{1}/
+http://{self.config.advertise_ip}:{self.config.advertise_port}/
urn:schemas-upnp-org:device:Basic:1
-Home Assistant Bridge ({0})
+Home Assistant Bridge ({self.config.advertise_ip})
Royal Philips Electronics
http://www.philips.com
Philips hue Personal Wireless Lighting
@@ -48,10 +48,6 @@ class DescriptionXmlView(HomeAssistantView):
"""
- resp_text = xml_template.format(
- self.config.advertise_ip, self.config.advertise_port
- )
-
return web.Response(text=resp_text, content_type="text/xml")
@@ -77,10 +73,10 @@ class UPNPResponderThread(threading.Thread):
# Note that the double newline at the end of
# this string is required per the SSDP spec
- resp_template = """HTTP/1.1 200 OK
+ resp_template = f"""HTTP/1.1 200 OK
CACHE-CONTROL: max-age=60
EXT:
-LOCATION: http://{0}:{1}/description.xml
+LOCATION: http://{advertise_ip}:{advertise_port}/description.xml
SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/0.1
hue-bridgeid: 1234
ST: urn:schemas-upnp-org:device:basic:1
@@ -88,11 +84,7 @@ USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1
"""
- self.upnp_response = (
- resp_template.format(advertise_ip, advertise_port)
- .replace("\n", "\r\n")
- .encode("utf-8")
- )
+ self.upnp_response = resp_template.replace("\n", "\r\n").encode("utf-8")
def run(self):
"""Run the server."""
diff --git a/homeassistant/components/emulated_roku/config_flow.py b/homeassistant/components/emulated_roku/config_flow.py
index 0a6d54693ef..3e363e060c2 100644
--- a/homeassistant/components/emulated_roku/config_flow.py
+++ b/homeassistant/components/emulated_roku/config_flow.py
@@ -38,7 +38,7 @@ class EmulatedRokuFlowHandler(config_entries.ConfigFlow):
servers_num = len(configured_servers(self.hass))
if servers_num:
- default_name = "{} {}".format(DEFAULT_NAME, servers_num + 1)
+ default_name = f"{DEFAULT_NAME} {servers_num + 1}"
default_port = DEFAULT_PORT + servers_num
else:
default_name = DEFAULT_NAME
diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py
index 59ca10da791..45a20197a4a 100644
--- a/homeassistant/components/enocean/sensor.py
+++ b/homeassistant/components/enocean/sensor.py
@@ -16,6 +16,7 @@ from homeassistant.const import (
STATE_CLOSED,
STATE_OPEN,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
@@ -36,7 +37,7 @@ SENSOR_TYPE_WINDOWHANDLE = "windowhandle"
SENSOR_TYPES = {
SENSOR_TYPE_HUMIDITY: {
"name": "Humidity",
- "unit": "%",
+ "unit": UNIT_PERCENTAGE,
"icon": "mdi:water-percent",
"class": DEVICE_CLASS_HUMIDITY,
},
@@ -111,9 +112,7 @@ class EnOceanSensor(enocean.EnOceanDevice):
super().__init__(dev_id, dev_name)
self._sensor_type = sensor_type
self._device_class = SENSOR_TYPES[self._sensor_type]["class"]
- self._dev_name = "{} {}".format(
- SENSOR_TYPES[self._sensor_type]["name"], dev_name
- )
+ self._dev_name = f"{SENSOR_TYPES[self._sensor_type]['name']} {dev_name}"
self._unit_of_measurement = SENSOR_TYPES[self._sensor_type]["unit"]
self._icon = SENSOR_TYPES[self._sensor_type]["icon"]
self._state = None
diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py
index 2ecae21824e..0425accd06b 100644
--- a/homeassistant/components/entur_public_transport/sensor.py
+++ b/homeassistant/components/entur_public_transport/sensor.py
@@ -12,6 +12,7 @@ from homeassistant.const import (
CONF_LONGITUDE,
CONF_NAME,
CONF_SHOW_ON_MAP,
+ TIME_MINUTES,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
@@ -119,7 +120,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
entities = []
for place in data.all_stop_places_quays():
try:
- given_name = "{} {}".format(name, data.get_stop_info(place).name)
+ given_name = f"{name} {data.get_stop_info(place).name}"
except KeyError:
given_name = f"{name} {place}"
@@ -183,7 +184,7 @@ class EnturPublicTransportSensor(Entity):
@property
def unit_of_measurement(self) -> str:
"""Return the unit this state is expressed in."""
- return "min"
+ return TIME_MINUTES
@property
def icon(self) -> str:
@@ -230,9 +231,9 @@ class EnturPublicTransportSensor(Entity):
self._attributes[ATTR_NEXT_UP_AT] = calls[1].expected_departure_time.strftime(
"%H:%M"
)
- self._attributes[ATTR_NEXT_UP_IN] = "{} min".format(
- due_in_minutes(calls[1].expected_departure_time)
- )
+ self._attributes[
+ ATTR_NEXT_UP_IN
+ ] = f"{due_in_minutes(calls[1].expected_departure_time)} min"
self._attributes[ATTR_NEXT_UP_REALTIME] = calls[1].is_realtime
self._attributes[ATTR_NEXT_UP_DELAY] = calls[1].delay_in_min
@@ -241,8 +242,7 @@ class EnturPublicTransportSensor(Entity):
for i, call in enumerate(calls[2:]):
key_name = "departure_#" + str(i + 3)
- self._attributes[key_name] = "{}{} {}".format(
- "" if bool(call.is_realtime) else "ca. ",
- call.expected_departure_time.strftime("%H:%M"),
- call.front_display,
+ self._attributes[key_name] = (
+ f"{'' if bool(call.is_realtime) else 'ca. '}"
+ f"{call.expected_departure_time.strftime('%H:%M')} {call.front_display}"
)
diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py
index 4ef3e17fc46..d51b69f5713 100644
--- a/homeassistant/components/environment_canada/camera.py
+++ b/homeassistant/components/environment_canada/camera.py
@@ -1,9 +1,4 @@
-"""
-Support for the Environment Canada radar imagery.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/camera.environment_canada/
-"""
+"""Support for the Environment Canada radar imagery."""
import datetime
import logging
diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json
index a9d97dc6271..9b208c452e5 100644
--- a/homeassistant/components/environment_canada/manifest.json
+++ b/homeassistant/components/environment_canada/manifest.json
@@ -2,7 +2,7 @@
"domain": "environment_canada",
"name": "Environment Canada",
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
- "requirements": ["env_canada==0.0.34"],
+ "requirements": ["env_canada==0.0.35"],
"dependencies": [],
"codeowners": ["@michaeldavie"]
}
diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py
index 1568ba19d6b..601a7f2ba36 100644
--- a/homeassistant/components/environment_canada/sensor.py
+++ b/homeassistant/components/environment_canada/sensor.py
@@ -1,9 +1,4 @@
-"""
-Support for the Environment Canada weather service.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/sensor.environment_canada/
-"""
+"""Support for the Environment Canada weather service."""
from datetime import datetime, timedelta
import logging
import re
@@ -28,7 +23,6 @@ SCAN_INTERVAL = timedelta(minutes=10)
ATTR_UPDATED = "updated"
ATTR_STATION = "station"
-ATTR_DETAIL = "alert detail"
ATTR_TIME = "alert time"
CONF_ATTRIBUTION = "Data provided by Environment Canada"
@@ -119,7 +113,7 @@ class ECSensor(Entity):
metadata = self.ec_data.metadata
sensor_data = conditions.get(self.sensor_type)
- self._unique_id = "{}-{}".format(metadata["location"], self.sensor_type)
+ self._unique_id = f"{metadata['location']}-{self.sensor_type}"
self._attr = {}
self._name = sensor_data.get("label")
value = sensor_data.get("value")
@@ -127,10 +121,7 @@ class ECSensor(Entity):
if isinstance(value, list):
self._state = " | ".join([str(s.get("title")) for s in value])[:255]
self._attr.update(
- {
- ATTR_DETAIL: " | ".join([str(s.get("detail")) for s in value]),
- ATTR_TIME: " | ".join([str(s.get("date")) for s in value]),
- }
+ {ATTR_TIME: " | ".join([str(s.get("date")) for s in value])}
)
elif self.sensor_type == "tendency":
self._state = str(value).capitalize()
diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py
index 572543e39c4..10666b4a34e 100644
--- a/homeassistant/components/environment_canada/weather.py
+++ b/homeassistant/components/environment_canada/weather.py
@@ -1,9 +1,4 @@
-"""
-Platform for retrieving meteorological data from Environment Canada.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/weather.environmentcanada/
-"""
+"""Platform for retrieving meteorological data from Environment Canada."""
import datetime
import logging
import re
diff --git a/homeassistant/components/epsonworkforce/sensor.py b/homeassistant/components/epsonworkforce/sensor.py
index 3bb90eb0644..b2164325547 100644
--- a/homeassistant/components/epsonworkforce/sensor.py
+++ b/homeassistant/components/epsonworkforce/sensor.py
@@ -6,19 +6,19 @@ from epsonprinter_pkg.epsonprinterapi import EpsonPrinterAPI
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_HOST, CONF_MONITORED_CONDITIONS
+from homeassistant.const import CONF_HOST, CONF_MONITORED_CONDITIONS, UNIT_PERCENTAGE
from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
_LOGGER = logging.getLogger(__name__)
MONITORED_CONDITIONS = {
- "black": ["Ink level Black", "%", "mdi:water"],
- "photoblack": ["Ink level Photoblack", "%", "mdi:water"],
- "magenta": ["Ink level Magenta", "%", "mdi:water"],
- "cyan": ["Ink level Cyan", "%", "mdi:water"],
- "yellow": ["Ink level Yellow", "%", "mdi:water"],
- "clean": ["Cleaning level", "%", "mdi:water"],
+ "black": ["Ink level Black", UNIT_PERCENTAGE, "mdi:water"],
+ "photoblack": ["Ink level Photoblack", UNIT_PERCENTAGE, "mdi:water"],
+ "magenta": ["Ink level Magenta", UNIT_PERCENTAGE, "mdi:water"],
+ "cyan": ["Ink level Cyan", UNIT_PERCENTAGE, "mdi:water"],
+ "yellow": ["Ink level Yellow", UNIT_PERCENTAGE, "mdi:water"],
+ "clean": ["Cleaning level", UNIT_PERCENTAGE, "mdi:water"],
}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py
index cabba95ea7e..9fbe3eff822 100644
--- a/homeassistant/components/esphome/__init__.py
+++ b/homeassistant/components/esphome/__init__.py
@@ -39,20 +39,11 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType
# Import config flow so that it's added to the registry
from .config_flow import EsphomeFlowHandler # noqa: F401
-from .entry_data import (
- DATA_KEY,
- DISPATCHER_ON_DEVICE_UPDATE,
- DISPATCHER_ON_LIST,
- DISPATCHER_ON_STATE,
- DISPATCHER_REMOVE_ENTITY,
- DISPATCHER_UPDATE_ENTITY,
- RuntimeEntryData,
-)
+from .entry_data import DATA_KEY, RuntimeEntryData
DOMAIN = "esphome"
_LOGGER = logging.getLogger(__name__)
-STORAGE_KEY = "esphome.{}"
STORAGE_VERSION = 1
# No config schema - only configuration entry
@@ -85,7 +76,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool
# Store client in per-config-entry hass.data
store = Store(
- hass, STORAGE_VERSION, STORAGE_KEY.format(entry.entry_id), encoder=JSONEncoder
+ hass, STORAGE_VERSION, f"esphome.{entry.entry_id}", encoder=JSONEncoder
)
entry_data = hass.data[DATA_KEY][entry.entry_id] = RuntimeEntryData(
client=cli, entry_id=entry.entry_id, store=store
@@ -403,7 +394,7 @@ async def platform_async_setup_entry(
# Add entities to Home Assistant
async_add_entities(add_entities)
- signal = DISPATCHER_ON_LIST.format(entry_id=entry.entry_id)
+ signal = f"esphome_{entry.entry_id}_on_list"
entry_data.cleanup_callbacks.append(
async_dispatcher_connect(hass, signal, async_list_entities)
)
@@ -416,7 +407,7 @@ async def platform_async_setup_entry(
entry_data.state[component_key][state.key] = state
entry_data.async_update_entity(hass, component_key, state.key)
- signal = DISPATCHER_ON_STATE.format(entry_id=entry.entry_id)
+ signal = f"esphome_{entry.entry_id}_on_state"
entry_data.cleanup_callbacks.append(
async_dispatcher_connect(hass, signal, async_entity_state)
)
@@ -490,21 +481,29 @@ class EsphomeEntity(Entity):
self._remove_callbacks.append(
async_dispatcher_connect(
self.hass,
- DISPATCHER_UPDATE_ENTITY.format(**kwargs),
+ (
+ f"esphome_{kwargs.get('entry_id')}"
+ f"_update_{kwargs.get('component_key')}_{kwargs.get('key')}"
+ ),
self._on_state_update,
)
)
self._remove_callbacks.append(
async_dispatcher_connect(
- self.hass, DISPATCHER_REMOVE_ENTITY.format(**kwargs), self.async_remove
+ self.hass,
+ (
+ f"esphome_{kwargs.get('entry_id')}_remove_"
+ f"{kwargs.get('component_key')}_{kwargs.get('key')}"
+ ),
+ self.async_remove,
)
)
self._remove_callbacks.append(
async_dispatcher_connect(
self.hass,
- DISPATCHER_ON_DEVICE_UPDATE.format(**kwargs),
+ f"esphome_{kwargs.get('entry_id')}_on_device_update",
self._on_device_update,
)
)
diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py
index c56760e952f..d8453c974f6 100644
--- a/homeassistant/components/esphome/entry_data.py
+++ b/homeassistant/components/esphome/entry_data.py
@@ -27,11 +27,6 @@ from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import HomeAssistantType
DATA_KEY = "esphome"
-DISPATCHER_UPDATE_ENTITY = "esphome_{entry_id}_update_{component_key}_{key}"
-DISPATCHER_REMOVE_ENTITY = "esphome_{entry_id}_remove_{component_key}_{key}"
-DISPATCHER_ON_LIST = "esphome_{entry_id}_on_list"
-DISPATCHER_ON_DEVICE_UPDATE = "esphome_{entry_id}_on_device_update"
-DISPATCHER_ON_STATE = "esphome_{entry_id}_on_state"
# Mapping from ESPHome info type to HA platform
INFO_TYPE_TO_PLATFORM = {
@@ -77,9 +72,7 @@ class RuntimeEntryData:
self, hass: HomeAssistantType, component_key: str, key: int
) -> None:
"""Schedule the update of an entity."""
- signal = DISPATCHER_UPDATE_ENTITY.format(
- entry_id=self.entry_id, component_key=component_key, key=key
- )
+ signal = f"esphome_{self.entry_id}_update_{component_key}_{key}"
async_dispatcher_send(hass, signal)
@callback
@@ -87,9 +80,7 @@ class RuntimeEntryData:
self, hass: HomeAssistantType, component_key: str, key: int
) -> None:
"""Schedule the removal of an entity."""
- signal = DISPATCHER_REMOVE_ENTITY.format(
- entry_id=self.entry_id, component_key=component_key, key=key
- )
+ signal = f"esphome_{self.entry_id}_remove_{component_key}_{key}"
async_dispatcher_send(hass, signal)
async def _ensure_platforms_loaded(
@@ -120,19 +111,19 @@ class RuntimeEntryData:
await self._ensure_platforms_loaded(hass, entry, needed_platforms)
# Then send dispatcher event
- signal = DISPATCHER_ON_LIST.format(entry_id=self.entry_id)
+ signal = f"esphome_{self.entry_id}_on_list"
async_dispatcher_send(hass, signal, infos)
@callback
def async_update_state(self, hass: HomeAssistantType, state: EntityState) -> None:
"""Distribute an update of state information to all platforms."""
- signal = DISPATCHER_ON_STATE.format(entry_id=self.entry_id)
+ signal = f"esphome_{self.entry_id}_on_state"
async_dispatcher_send(hass, signal, state)
@callback
def async_update_device_state(self, hass: HomeAssistantType) -> None:
"""Distribute an update of a core device state like availability."""
- signal = DISPATCHER_ON_DEVICE_UPDATE.format(entry_id=self.entry_id)
+ signal = f"esphome_{self.entry_id}_on_device_update"
async_dispatcher_send(hass, signal)
async def async_load_from_store(self) -> Tuple[List[EntityInfo], List[UserService]]:
diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py
index e50991af6c1..0856f270710 100644
--- a/homeassistant/components/esphome/sensor.py
+++ b/homeassistant/components/esphome/sensor.py
@@ -69,9 +69,7 @@ class EsphomeSensor(EsphomeEntity):
return None
if self._state.missing_state:
return None
- return "{:.{prec}f}".format(
- self._state.state, prec=self._static_info.accuracy_decimals
- )
+ return f"{self._state.state:.{self._static_info.accuracy_decimals}f}"
@property
def unit_of_measurement(self) -> str:
diff --git a/homeassistant/components/everlights/light.py b/homeassistant/components/everlights/light.py
index f7fa9deffa0..da9d5b88ae0 100644
--- a/homeassistant/components/everlights/light.py
+++ b/homeassistant/components/everlights/light.py
@@ -32,8 +32,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{vol.Required(CONF_HOSTS): vol.All(cv.ensure_list, [cv.string])}
)
-NAME_FORMAT = "EverLights {} Zone {}"
-
def color_rgb_to_int(red: int, green: int, blue: int) -> int:
"""Return a RGB color as an integer."""
@@ -96,7 +94,7 @@ class EverLightsLight(Light):
@property
def name(self):
"""Return the name of the device."""
- return NAME_FORMAT.format(self._mac, self._channel)
+ return f"EverLights {self._mac} Zone {self._channel}"
@property
def is_on(self):
diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py
index 1a408c0a660..f56c92d6572 100644
--- a/homeassistant/components/evohome/__init__.py
+++ b/homeassistant/components/evohome/__init__.py
@@ -34,7 +34,7 @@ from homeassistant.helpers.service import verify_domain_control
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
import homeassistant.util.dt as dt_util
-from .const import DOMAIN, EVO_FOLLOW, GWS, STORAGE_KEY, STORAGE_VERSION, TCS
+from .const import DOMAIN, EVO_FOLLOW, GWS, STORAGE_KEY, STORAGE_VER, TCS, UTC_OFFSET
_LOGGER = logging.getLogger(__name__)
@@ -93,22 +93,22 @@ SET_ZONE_OVERRIDE_SCHEMA = vol.Schema(
# system mode schemas are built dynamically, below
-def _local_dt_to_aware(dt_naive: dt) -> dt:
+def _dt_local_to_aware(dt_naive: dt) -> dt:
dt_aware = dt_util.now() + (dt_naive - dt.now())
if dt_aware.microsecond >= 500000:
dt_aware += timedelta(seconds=1)
return dt_aware.replace(microsecond=0)
-def _dt_to_local_naive(dt_aware: dt) -> dt:
+def _dt_aware_to_naive(dt_aware: dt) -> dt:
dt_naive = dt.now() + (dt_aware - dt_util.now())
if dt_naive.microsecond >= 500000:
dt_naive += timedelta(seconds=1)
return dt_naive.replace(microsecond=0)
-def convert_until(status_dict, until_key) -> str:
- """Convert datetime string from "%Y-%m-%dT%H:%M:%SZ" to local/aware/isoformat."""
+def convert_until(status_dict: dict, until_key: str) -> str:
+ """Reformat a dt str from "%Y-%m-%dT%H:%M:%SZ" as local/aware/isoformat."""
if until_key in status_dict: # only present for certain modes
dt_utc_naive = dt_util.parse_datetime(status_dict[until_key])
status_dict[until_key] = dt_util.as_local(dt_utc_naive).isoformat()
@@ -190,14 +190,14 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
# evohomeasync2 requires naive/local datetimes as strings
if tokens.get(ACCESS_TOKEN_EXPIRES) is not None:
- tokens[ACCESS_TOKEN_EXPIRES] = _dt_to_local_naive(
+ tokens[ACCESS_TOKEN_EXPIRES] = _dt_aware_to_naive(
dt_util.parse_datetime(tokens[ACCESS_TOKEN_EXPIRES])
)
user_data = tokens.pop(USER_DATA, None)
return (tokens, user_data)
- store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
+ store = hass.helpers.storage.Store(STORAGE_VER, STORAGE_KEY)
tokens, user_data = await load_auth_tokens(store)
client_v2 = evohomeasync2.EvohomeClient(
@@ -217,7 +217,7 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
loc_idx = config[DOMAIN][CONF_LOCATION_IDX]
try:
- loc_config = client_v2.installation_info[loc_idx][GWS][0][TCS][0]
+ loc_config = client_v2.installation_info[loc_idx]
except IndexError:
_LOGGER.error(
"Config error: '%s' = %s, but the valid range is 0-%s. "
@@ -228,7 +228,11 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
)
return False
- _LOGGER.debug("Config = %s", loc_config)
+ if _LOGGER.isEnabledFor(logging.DEBUG):
+ _config = {"locationInfo": {"timeZone": None}, GWS: [{TCS: None}]}
+ _config["locationInfo"]["timeZone"] = loc_config["locationInfo"]["timeZone"]
+ _config[GWS][0][TCS] = loc_config[GWS][0][TCS]
+ _LOGGER.debug("Config = %s", _config)
client_v1 = evohomeasync.EvohomeClient(
client_v2.username,
@@ -393,12 +397,15 @@ class EvoBroker:
loc_idx = params[CONF_LOCATION_IDX]
self.config = client.installation_info[loc_idx][GWS][0][TCS][0]
self.tcs = client.locations[loc_idx]._gateways[0]._control_systems[0]
+ self.tcs_utc_offset = timedelta(
+ minutes=client.locations[loc_idx].timeZone[UTC_OFFSET]
+ )
self.temps = {}
async def save_auth_tokens(self) -> None:
"""Save access tokens and session IDs to the store for later use."""
# evohomeasync2 uses naive/local datetimes
- access_token_expires = _local_dt_to_aware(self.client.access_token_expires)
+ access_token_expires = _dt_local_to_aware(self.client.access_token_expires)
app_storage = {CONF_USERNAME: self.client.username}
app_storage[REFRESH_TOKEN] = self.client.refresh_token
@@ -481,7 +488,7 @@ class EvoBroker:
else:
async_dispatcher_send(self.hass, DOMAIN)
- _LOGGER.debug("Status = %s", status[GWS][0][TCS][0])
+ _LOGGER.debug("Status = %s", status)
if access_token != self.client.access_token:
await self.save_auth_tokens()
@@ -621,6 +628,11 @@ class EvoChild(EvoDevice):
Only Zones & DHW controllers (but not the TCS) can have schedules.
"""
+
+ def _dt_evo_to_aware(dt_naive: dt, utc_offset: timedelta) -> dt:
+ dt_aware = dt_naive.replace(tzinfo=dt_util.UTC) - utc_offset
+ return dt_util.as_local(dt_aware)
+
if not self._schedule["DailySchedules"]:
return {} # no schedule {'DailySchedules': []}, so no scheduled setpoints
@@ -650,11 +662,12 @@ class EvoChild(EvoDevice):
day = self._schedule["DailySchedules"][(day_of_week + offset) % 7]
switchpoint = day["Switchpoints"][idx]
- dt_local_aware = _local_dt_to_aware(
- dt_util.parse_datetime(f"{sp_date}T{switchpoint['TimeOfDay']}")
+ dt_aware = _dt_evo_to_aware(
+ dt_util.parse_datetime(f"{sp_date}T{switchpoint['TimeOfDay']}"),
+ self._evo_broker.tcs_utc_offset,
)
- self._setpoints[f"{key}_sp_from"] = dt_local_aware.isoformat()
+ self._setpoints[f"{key}_sp_from"] = dt_aware.isoformat()
try:
self._setpoints[f"{key}_sp_temp"] = switchpoint["heatSetpoint"]
except KeyError:
diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py
index aece0f0ec0d..b7899afdd7b 100644
--- a/homeassistant/components/evohome/climate.py
+++ b/homeassistant/components/evohome/climate.py
@@ -20,7 +20,7 @@ from homeassistant.components.climate.const import (
)
from homeassistant.const import PRECISION_TENTHS
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
-from homeassistant.util.dt import parse_datetime
+import homeassistant.util.dt as dt_util
from . import (
ATTR_DURATION_DAYS,
@@ -149,7 +149,12 @@ class EvoZone(EvoChild, EvoClimateDevice):
"""Initialize a Honeywell TCC Zone."""
super().__init__(evo_broker, evo_device)
- self._unique_id = evo_device.zoneId
+ if evo_device.modelType.startswith("VisionProWifi"):
+ # this system does not have a distinct ID for the zone
+ self._unique_id = f"{evo_device.zoneId}z"
+ else:
+ self._unique_id = evo_device.zoneId
+
self._name = evo_device.name
self._icon = "mdi:radiator"
@@ -170,21 +175,21 @@ class EvoZone(EvoChild, EvoClimateDevice):
return
# otherwise it is SVC_SET_ZONE_OVERRIDE
- temp = round(data[ATTR_ZONE_TEMP] * self.precision) / self.precision
- temp = max(min(temp, self.max_temp), self.min_temp)
+ temperature = max(min(data[ATTR_ZONE_TEMP], self.max_temp), self.min_temp)
if ATTR_DURATION_UNTIL in data:
duration = data[ATTR_DURATION_UNTIL]
if duration.total_seconds() == 0:
await self._update_schedule()
- until = parse_datetime(str(self.setpoints.get("next_sp_from")))
+ until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", ""))
else:
- until = dt.now() + data[ATTR_DURATION_UNTIL]
+ until = dt_util.now() + data[ATTR_DURATION_UNTIL]
else:
until = None # indefinitely
+ until = dt_util.as_utc(until) if until else None
await self._evo_broker.call_client_api(
- self._evo_device.set_temperature(temperature=temp, until=until)
+ self._evo_device.set_temperature(temperature, until=until)
)
@property
@@ -244,12 +249,13 @@ class EvoZone(EvoChild, EvoClimateDevice):
if until is None:
if self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW:
await self._update_schedule()
- until = parse_datetime(str(self.setpoints.get("next_sp_from")))
+ until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", ""))
elif self._evo_device.setpointStatus["setpointMode"] == EVO_TEMPOVER:
- until = parse_datetime(self._evo_device.setpointStatus["until"])
+ until = dt_util.parse_datetime(self._evo_device.setpointStatus["until"])
+ until = dt_util.as_utc(until) if until else None
await self._evo_broker.call_client_api(
- self._evo_device.set_temperature(temperature, until)
+ self._evo_device.set_temperature(temperature, until=until)
)
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
@@ -292,12 +298,13 @@ class EvoZone(EvoChild, EvoClimateDevice):
if evo_preset_mode == EVO_TEMPOVER:
await self._update_schedule()
- until = parse_datetime(str(self.setpoints.get("next_sp_from")))
+ until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", ""))
else: # EVO_PERMOVER
until = None
+ until = dt_util.as_utc(until) if until else None
await self._evo_broker.call_client_api(
- self._evo_device.set_temperature(temperature, until)
+ self._evo_device.set_temperature(temperature, until=until)
)
async def async_update(self) -> None:
@@ -345,11 +352,11 @@ class EvoController(EvoClimateDevice):
mode = EVO_RESET
if ATTR_DURATION_DAYS in data:
- until = dt.combine(dt.now().date(), dt.min.time())
+ until = dt_util.start_of_local_day()
until += data[ATTR_DURATION_DAYS]
elif ATTR_DURATION_HOURS in data:
- until = dt.now() + data[ATTR_DURATION_HOURS]
+ until = dt_util.now() + data[ATTR_DURATION_HOURS]
else:
until = None
@@ -358,7 +365,10 @@ class EvoController(EvoClimateDevice):
async def _set_tcs_mode(self, mode: str, until: Optional[dt] = None) -> None:
"""Set a Controller to any of its native EVO_* operating modes."""
- await self._evo_broker.call_client_api(self._evo_tcs.set_status(mode))
+ until = dt_util.as_utc(until) if until else None
+ await self._evo_broker.call_client_api(
+ self._evo_tcs.set_status(mode, until=until)
+ )
@property
def hvac_mode(self) -> str:
diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py
index eaa7048e53b..6bd3a59c225 100644
--- a/homeassistant/components/evohome/const.py
+++ b/homeassistant/components/evohome/const.py
@@ -1,7 +1,7 @@
"""Support for (EMEA/EU-based) Honeywell TCC climate systems."""
DOMAIN = "evohome"
-STORAGE_VERSION = 1
+STORAGE_VER = 1
STORAGE_KEY = DOMAIN
# The Parent's (i.e. TCS, Controller's) operating mode is one of:
@@ -21,3 +21,5 @@ EVO_PERMOVER = "PermanentOverride"
# These are used only to help prevent E501 (line too long) violations
GWS = "gateways"
TCS = "temperatureControlSystems"
+
+UTC_OFFSET = "currentOffsetMinutes"
diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py
index cc282534f1b..20aa0710d0d 100644
--- a/homeassistant/components/evohome/water_heater.py
+++ b/homeassistant/components/evohome/water_heater.py
@@ -9,7 +9,7 @@ from homeassistant.components.water_heater import (
)
from homeassistant.const import PRECISION_TENTHS, PRECISION_WHOLE, STATE_OFF, STATE_ON
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
-from homeassistant.util.dt import parse_datetime
+import homeassistant.util.dt as dt_util
from . import EvoChild
from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER
@@ -90,15 +90,16 @@ class EvoDHW(EvoChild, WaterHeaterDevice):
await self._evo_broker.call_client_api(self._evo_device.set_dhw_auto())
else:
await self._update_schedule()
- until = parse_datetime(str(self.setpoints.get("next_sp_from")))
+ until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", ""))
+ until = dt_util.as_utc(until) if until else None
if operation_mode == STATE_ON:
await self._evo_broker.call_client_api(
- self._evo_device.set_dhw_on(until)
+ self._evo_device.set_dhw_on(until=until)
)
else: # STATE_OFF
await self._evo_broker.call_client_api(
- self._evo_device.set_dhw_off(until)
+ self._evo_device.set_dhw_off(until=until)
)
async def async_turn_away_mode_on(self):
diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py
new file mode 100644
index 00000000000..96891e8b291
--- /dev/null
+++ b/homeassistant/components/ezviz/__init__.py
@@ -0,0 +1 @@
+"""Support for Ezviz devices via Ezviz Cloud API."""
diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py
new file mode 100644
index 00000000000..b8ede42a508
--- /dev/null
+++ b/homeassistant/components/ezviz/camera.py
@@ -0,0 +1,237 @@
+"""This component provides basic support for Ezviz IP cameras."""
+import asyncio
+import logging
+
+from haffmpeg.tools import IMAGE_JPEG, ImageFrame
+from pyezviz.camera import EzvizCamera
+from pyezviz.client import EzvizClient, PyEzvizError
+import voluptuous as vol
+
+from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.helpers import config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CAMERAS = "cameras"
+
+DEFAULT_CAMERA_USERNAME = "admin"
+DEFAULT_RTSP_PORT = "554"
+
+DATA_FFMPEG = "ffmpeg"
+
+EZVIZ_DATA = "ezviz"
+ENTITIES = "entities"
+
+CAMERA_SCHEMA = vol.Schema(
+ {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string}
+)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
+ {
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_CAMERAS, default={}): {cv.string: CAMERA_SCHEMA},
+ }
+)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Ezviz IP Cameras."""
+
+ conf_cameras = config[CONF_CAMERAS]
+
+ account = config[CONF_USERNAME]
+ password = config[CONF_PASSWORD]
+
+ try:
+ ezviz_client = EzvizClient(account, password)
+ ezviz_client.login()
+ cameras = ezviz_client.load_cameras()
+
+ except PyEzvizError as exp:
+ _LOGGER.error(exp)
+ return
+
+ # now, let's build the HASS devices
+ camera_entities = []
+
+ # Add the cameras as devices in HASS
+ for camera in cameras:
+
+ camera_username = DEFAULT_CAMERA_USERNAME
+ camera_password = ""
+ camera_rtsp_stream = ""
+ camera_serial = camera["serial"]
+
+ # There seem to be a bug related to localRtspPort in Ezviz API...
+ local_rtsp_port = DEFAULT_RTSP_PORT
+ if camera["local_rtsp_port"] and camera["local_rtsp_port"] != 0:
+ local_rtsp_port = camera["local_rtsp_port"]
+
+ if camera_serial in conf_cameras:
+ camera_username = conf_cameras[camera_serial][CONF_USERNAME]
+ camera_password = conf_cameras[camera_serial][CONF_PASSWORD]
+ camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{camera['local_ip']}:{local_rtsp_port}"
+ _LOGGER.debug(
+ "Camera %s source stream: %s", camera["serial"], camera_rtsp_stream
+ )
+
+ else:
+ _LOGGER.info(
+ "Found camera with serial %s without configuration. Add it to configuration.yaml to see the camera stream",
+ camera_serial,
+ )
+
+ camera["username"] = camera_username
+ camera["password"] = camera_password
+ camera["rtsp_stream"] = camera_rtsp_stream
+
+ camera["ezviz_camera"] = EzvizCamera(ezviz_client, camera_serial)
+
+ camera_entities.append(HassEzvizCamera(**camera))
+
+ add_entities(camera_entities)
+
+
+class HassEzvizCamera(Camera):
+ """An implementation of a Foscam IP camera."""
+
+ def __init__(self, **data):
+ """Initialize an Ezviz camera."""
+ super().__init__()
+
+ self._username = data["username"]
+ self._password = data["password"]
+ self._rtsp_stream = data["rtsp_stream"]
+
+ self._ezviz_camera = data["ezviz_camera"]
+ self._serial = data["serial"]
+ self._name = data["name"]
+ self._status = data["status"]
+ self._privacy = data["privacy"]
+ self._audio = data["audio"]
+ self._ir_led = data["ir_led"]
+ self._state_led = data["state_led"]
+ self._follow_move = data["follow_move"]
+ self._alarm_notify = data["alarm_notify"]
+ self._alarm_sound_mod = data["alarm_sound_mod"]
+ self._encrypted = data["encrypted"]
+ self._local_ip = data["local_ip"]
+ self._detection_sensibility = data["detection_sensibility"]
+ self._device_sub_category = data["device_sub_category"]
+ self._local_rtsp_port = data["local_rtsp_port"]
+
+ self._ffmpeg = None
+
+ def update(self):
+ """Update the camera states."""
+
+ data = self._ezviz_camera.status()
+
+ self._name = data["name"]
+ self._status = data["status"]
+ self._privacy = data["privacy"]
+ self._audio = data["audio"]
+ self._ir_led = data["ir_led"]
+ self._state_led = data["state_led"]
+ self._follow_move = data["follow_move"]
+ self._alarm_notify = data["alarm_notify"]
+ self._alarm_sound_mod = data["alarm_sound_mod"]
+ self._encrypted = data["encrypted"]
+ self._local_ip = data["local_ip"]
+ self._detection_sensibility = data["detection_sensibility"]
+ self._device_sub_category = data["device_sub_category"]
+ self._local_rtsp_port = data["local_rtsp_port"]
+
+ async def async_added_to_hass(self):
+ """Subscribe to ffmpeg and add camera to list."""
+ self._ffmpeg = self.hass.data[DATA_FFMPEG]
+
+ @property
+ def should_poll(self) -> bool:
+ """Return True if entity has to be polled for state.
+
+ False if entity pushes its state to HA.
+ """
+ return True
+
+ @property
+ def device_state_attributes(self):
+ """Return the Ezviz-specific camera state attributes."""
+ return {
+ # if privacy == true, the device closed the lid or did a 180° tilt
+ "privacy": self._privacy,
+ # is the camera listening ?
+ "audio": self._audio,
+ # infrared led on ?
+ "ir_led": self._ir_led,
+ # state led on ?
+ "state_led": self._state_led,
+ # if true, the camera will move automatically to follow movements
+ "follow_move": self._follow_move,
+ # if true, if some movement is detected, the app is notified
+ "alarm_notify": self._alarm_notify,
+ # if true, if some movement is detected, the camera makes some sound
+ "alarm_sound_mod": self._alarm_sound_mod,
+ # are the camera's stored videos/images encrypted?
+ "encrypted": self._encrypted,
+ # camera's local ip on local network
+ "local_ip": self._local_ip,
+ # from 1 to 9, the higher is the sensibility, the more it will detect small movements
+ "detection_sensibility": self._detection_sensibility,
+ }
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._status
+
+ @property
+ def brand(self):
+ """Return the camera brand."""
+ return "Ezviz"
+
+ @property
+ def supported_features(self):
+ """Return supported features."""
+ if self._rtsp_stream:
+ return SUPPORT_STREAM
+ return 0
+
+ @property
+ def model(self):
+ """Return the camera model."""
+ return self._device_sub_category
+
+ @property
+ def is_on(self):
+ """Return true if on."""
+ return self._status
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
+
+ async def async_camera_image(self):
+ """Return a frame from the camera stream."""
+ ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop)
+
+ image = await asyncio.shield(
+ ffmpeg.get_image(self._rtsp_stream, output_format=IMAGE_JPEG,)
+ )
+ return image
+
+ async def stream_source(self):
+ """Return the stream source."""
+ if self._local_rtsp_port:
+ rtsp_stream_source = "rtsp://{}:{}@{}:{}".format(
+ self._username, self._password, self._local_ip, self._local_rtsp_port
+ )
+ _LOGGER.debug(
+ "Camera %s source stream: %s", self._serial, rtsp_stream_source
+ )
+ self._rtsp_stream = rtsp_stream_source
+ return rtsp_stream_source
+ return None
diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json
new file mode 100644
index 00000000000..167f063c0f7
--- /dev/null
+++ b/homeassistant/components/ezviz/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "ezviz",
+ "name": "Ezviz",
+ "documentation": "https://www.home-assistant.io/integrations/ezviz",
+ "dependencies": [],
+ "codeowners": ["@baqs"],
+ "requirements": ["pyezviz==0.1.5"]
+}
diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py
index dbd9be61516..34d5b14cf25 100644
--- a/homeassistant/components/facebook/notify.py
+++ b/homeassistant/components/facebook/notify.py
@@ -19,8 +19,6 @@ _LOGGER = logging.getLogger(__name__)
CONF_PAGE_ACCESS_TOKEN = "page_access_token"
BASE_URL = "https://graph.facebook.com/v2.6/me/messages"
-CREATE_BROADCAST_URL = "https://graph.facebook.com/v2.11/me/message_creatives"
-SEND_BROADCAST_URL = "https://graph.facebook.com/v2.11/me/broadcast_messages"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{vol.Required(CONF_PAGE_ACCESS_TOKEN): cv.string}
@@ -57,29 +55,23 @@ class FacebookNotificationService(BaseNotificationService):
_LOGGER.error("At least 1 target is required")
return
- # broadcast message
- if targets[0].lower() == "broadcast":
- broadcast_create_body = {"messages": [body_message]}
- _LOGGER.debug("Broadcast body %s : ", broadcast_create_body)
+ for target in targets:
+ # If the target starts with a "+", it's a phone number,
+ # otherwise it's a user id.
+ if target.startswith("+"):
+ recipient = {"phone_number": target}
+ else:
+ recipient = {"id": target}
- resp = requests.post(
- CREATE_BROADCAST_URL,
- data=json.dumps(broadcast_create_body),
- params=payload,
- headers={CONTENT_TYPE: CONTENT_TYPE_JSON},
- timeout=10,
- )
- _LOGGER.debug("FB Messager broadcast id %s : ", resp.json())
-
- # at this point we get broadcast id
- broadcast_body = {
- "message_creative_id": resp.json().get("message_creative_id"),
- "notification_type": "REGULAR",
+ body = {
+ "recipient": recipient,
+ "message": body_message,
+ "messaging_type": "MESSAGE_TAG",
+ "tag": "ACCOUNT_UPDATE",
}
-
resp = requests.post(
- SEND_BROADCAST_URL,
- data=json.dumps(broadcast_body),
+ BASE_URL,
+ data=json.dumps(body),
params=payload,
headers={CONTENT_TYPE: CONTENT_TYPE_JSON},
timeout=10,
@@ -87,32 +79,6 @@ class FacebookNotificationService(BaseNotificationService):
if resp.status_code != 200:
log_error(resp)
- # non-broadcast message
- else:
- for target in targets:
- # If the target starts with a "+", it's a phone number,
- # otherwise it's a user id.
- if target.startswith("+"):
- recipient = {"phone_number": target}
- else:
- recipient = {"id": target}
-
- body = {
- "recipient": recipient,
- "message": body_message,
- "messaging_type": "MESSAGE_TAG",
- "tag": "ACCOUNT_UPDATE",
- }
- resp = requests.post(
- BASE_URL,
- data=json.dumps(body),
- params=payload,
- headers={CONTENT_TYPE: CONTENT_TYPE_JSON},
- timeout=10,
- )
- if resp.status_code != 200:
- log_error(resp)
-
def log_error(response):
"""Log error message."""
diff --git a/homeassistant/components/fail2ban/sensor.py b/homeassistant/components/fail2ban/sensor.py
index 692b48d9db5..6e47cb45966 100644
--- a/homeassistant/components/fail2ban/sensor.py
+++ b/homeassistant/components/fail2ban/sensor.py
@@ -1,10 +1,4 @@
-"""
-Support for displaying IPs banned by fail2ban.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/sensor.fail2ban/
-
-"""
+"""Support for displaying IPs banned by fail2ban."""
from datetime import timedelta
import logging
import os
diff --git a/homeassistant/components/fan/manifest.json b/homeassistant/components/fan/manifest.json
index 02ed368feac..53b7873612c 100644
--- a/homeassistant/components/fan/manifest.json
+++ b/homeassistant/components/fan/manifest.json
@@ -3,7 +3,7 @@
"name": "Fan",
"documentation": "https://www.home-assistant.io/integrations/fan",
"requirements": [],
- "dependencies": ["group"],
+ "dependencies": [],
"codeowners": [],
"quality_scale": "internal"
}
diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml
index ee478950095..1fbd9089ed7 100644
--- a/homeassistant/components/fan/services.yaml
+++ b/homeassistant/components/fan/services.yaml
@@ -48,7 +48,7 @@ set_direction:
description: Set the fan rotation.
fields:
entity_id:
- description: Name(s) of the entities to toggle
+ description: Name(s) of the entities to set
example: 'fan.living_room'
direction:
description: The direction to rotate. Either 'forward' or 'reverse'
diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py
index 2643607c3a8..548654c11c0 100644
--- a/homeassistant/components/feedreader/__init__.py
+++ b/homeassistant/components/feedreader/__init__.py
@@ -102,11 +102,13 @@ class FeedManager:
# during the initial parsing of the XML, but it doesn't indicate
# whether this is an unrecoverable error. In this case the
# feedparser lib is trying a less strict parsing approach.
- # If an error is detected here, log error message but continue
+ # If an error is detected here, log warning message but continue
# processing the feed entries if present.
if self._feed.bozo != 0:
- _LOGGER.error(
- "Error parsing feed %s: %s", self._url, self._feed.bozo_exception
+ _LOGGER.warning(
+ "Possible issue parsing feed %s: %s",
+ self._url,
+ self._feed.bozo_exception,
)
# Using etag and modified, if there's no new data available,
# the entries list will be empty
diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py
index 52ecb881205..89529046f85 100644
--- a/homeassistant/components/fibaro/__init__.py
+++ b/homeassistant/components/fibaro/__init__.py
@@ -247,8 +247,8 @@ class FibaroController:
room_name = self._room_map[device.roomID].name
device.room_name = room_name
device.friendly_name = f"{room_name} {device.name}"
- device.ha_id = "scene_{}_{}_{}".format(
- slugify(room_name), slugify(device.name), device.id
+ device.ha_id = (
+ f"scene_{slugify(room_name)}_{slugify(device.name)}_{device.id}"
)
device.unique_id_str = f"{self.hub_serial}.scene.{device.id}"
self._scene_map[device.id] = device
@@ -269,8 +269,8 @@ class FibaroController:
room_name = self._room_map[device.roomID].name
device.room_name = room_name
device.friendly_name = f"{room_name} {device.name}"
- device.ha_id = "{}_{}_{}".format(
- slugify(room_name), slugify(device.name), device.id
+ device.ha_id = (
+ f"{slugify(room_name)}_{slugify(device.name)}_{device.id}"
)
if (
device.enabled
diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py
index af2c2a9401a..fa2d6ceb3c6 100644
--- a/homeassistant/components/fibaro/binary_sensor.py
+++ b/homeassistant/components/fibaro/binary_sensor.py
@@ -1,7 +1,7 @@
"""Support for Fibaro binary sensors."""
import logging
-from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorDevice
+from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice
from homeassistant.const import CONF_DEVICE_CLASS, CONF_ICON
from . import FIBARO_DEVICES, FibaroDevice
@@ -40,7 +40,7 @@ class FibaroBinarySensor(FibaroDevice, BinarySensorDevice):
"""Initialize the binary_sensor."""
self._state = None
super().__init__(fibaro_device)
- self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id)
+ self.entity_id = f"{DOMAIN}.{self.ha_id}"
stype = None
devconf = fibaro_device.device_config
if fibaro_device.type in SENSOR_TYPES:
diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py
index fe9c0990fa8..d2f8094f26d 100644
--- a/homeassistant/components/fibaro/cover.py
+++ b/homeassistant/components/fibaro/cover.py
@@ -4,7 +4,7 @@ import logging
from homeassistant.components.cover import (
ATTR_POSITION,
ATTR_TILT_POSITION,
- ENTITY_ID_FORMAT,
+ DOMAIN,
CoverDevice,
)
@@ -29,7 +29,7 @@ class FibaroCover(FibaroDevice, CoverDevice):
def __init__(self, fibaro_device):
"""Initialize the Vera device."""
super().__init__(fibaro_device)
- self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id)
+ self.entity_id = f"{DOMAIN}.{self.ha_id}"
@staticmethod
def bound(position):
diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py
index 38779a05cb0..d14d9a195d9 100644
--- a/homeassistant/components/fibaro/light.py
+++ b/homeassistant/components/fibaro/light.py
@@ -7,7 +7,7 @@ from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_HS_COLOR,
ATTR_WHITE_VALUE,
- ENTITY_ID_FORMAT,
+ DOMAIN,
SUPPORT_BRIGHTNESS,
SUPPORT_COLOR,
SUPPORT_WHITE_VALUE,
@@ -77,7 +77,7 @@ class FibaroLight(FibaroDevice, Light):
self._supported_flags |= SUPPORT_WHITE_VALUE
super().__init__(fibaro_device)
- self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id)
+ self.entity_id = f"{DOMAIN}.{self.ha_id}"
@property
def brightness(self):
diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py
index 1e0bae212f8..68a39431a98 100644
--- a/homeassistant/components/fibaro/sensor.py
+++ b/homeassistant/components/fibaro/sensor.py
@@ -1,13 +1,15 @@
"""Support for Fibaro sensors."""
import logging
-from homeassistant.components.sensor import ENTITY_ID_FORMAT
+from homeassistant.components.sensor import DOMAIN
from homeassistant.const import (
+ CONCENTRATION_PARTS_PER_MILLION,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_TEMPERATURE,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers.entity import Entity
@@ -20,9 +22,19 @@ SENSOR_TYPES = {
None,
DEVICE_CLASS_TEMPERATURE,
],
- "com.fibaro.smokeSensor": ["Smoke", "ppm", "mdi:fire", None],
- "CO2": ["CO2", "ppm", "mdi:cloud", None],
- "com.fibaro.humiditySensor": ["Humidity", "%", None, DEVICE_CLASS_HUMIDITY],
+ "com.fibaro.smokeSensor": [
+ "Smoke",
+ CONCENTRATION_PARTS_PER_MILLION,
+ "mdi:fire",
+ None,
+ ],
+ "CO2": ["CO2", CONCENTRATION_PARTS_PER_MILLION, "mdi:cloud", None],
+ "com.fibaro.humiditySensor": [
+ "Humidity",
+ UNIT_PERCENTAGE,
+ None,
+ DEVICE_CLASS_HUMIDITY,
+ ],
"com.fibaro.lightSensor": ["Light", "lx", None, DEVICE_CLASS_ILLUMINANCE],
}
@@ -47,7 +59,7 @@ class FibaroSensor(FibaroDevice, Entity):
self.current_value = None
self.last_changed_time = None
super().__init__(fibaro_device)
- self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id)
+ self.entity_id = f"{DOMAIN}.{self.ha_id}"
if fibaro_device.type in SENSOR_TYPES:
self._unit = SENSOR_TYPES[fibaro_device.type][1]
self._icon = SENSOR_TYPES[fibaro_device.type][2]
diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py
index 4bb8c34d579..b00e5817c9e 100644
--- a/homeassistant/components/fibaro/switch.py
+++ b/homeassistant/components/fibaro/switch.py
@@ -1,7 +1,7 @@
"""Support for Fibaro switches."""
import logging
-from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice
+from homeassistant.components.switch import DOMAIN, SwitchDevice
from homeassistant.util import convert
from . import FIBARO_DEVICES, FibaroDevice
@@ -26,7 +26,7 @@ class FibaroSwitch(FibaroDevice, SwitchDevice):
"""Initialize the Fibaro device."""
self._state = False
super().__init__(fibaro_device)
- self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id)
+ self.entity_id = f"{DOMAIN}.{self.ha_id}"
def turn_on(self, **kwargs):
"""Turn device on."""
diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py
index f444abd25ee..951d13dadb4 100644
--- a/homeassistant/components/fido/sensor.py
+++ b/homeassistant/components/fido/sensor.py
@@ -3,9 +3,6 @@ Support for Fido.
Get data from 'Usage Summary' page:
https://www.fido.ca/pages/#/my-account/wireless
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/sensor.fido/
"""
from datetime import timedelta
import logging
@@ -21,6 +18,7 @@ from homeassistant.const import (
CONF_PASSWORD,
CONF_USERNAME,
DATA_KILOBITS,
+ TIME_MINUTES,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -30,7 +28,6 @@ _LOGGER = logging.getLogger(__name__)
PRICE = "CAD"
MESSAGES = "messages"
-MINUTES = "minutes"
DEFAULT_NAME = "Fido"
@@ -52,12 +49,12 @@ SENSOR_TYPES = {
"text_int_used": ["International text used", MESSAGES, "mdi:message-alert"],
"text_int_limit": ["International text limit", MESSAGES, "mdi:message-alert"],
"text_int_remaining": ["International remaining", MESSAGES, "mdi:message-alert"],
- "talk_used": ["Talk used", MINUTES, "mdi:cellphone"],
- "talk_limit": ["Talk limit", MINUTES, "mdi:cellphone"],
- "talk_remaining": ["Talk remaining", MINUTES, "mdi:cellphone"],
- "other_talk_used": ["Other Talk used", MINUTES, "mdi:cellphone"],
- "other_talk_limit": ["Other Talk limit", MINUTES, "mdi:cellphone"],
- "other_talk_remaining": ["Other Talk remaining", MINUTES, "mdi:cellphone"],
+ "talk_used": ["Talk used", TIME_MINUTES, "mdi:cellphone"],
+ "talk_limit": ["Talk limit", TIME_MINUTES, "mdi:cellphone"],
+ "talk_remaining": ["Talk remaining", TIME_MINUTES, "mdi:cellphone"],
+ "other_talk_used": ["Other Talk used", TIME_MINUTES, "mdi:cellphone"],
+ "other_talk_limit": ["Other Talk limit", TIME_MINUTES, "mdi:cellphone"],
+ "other_talk_remaining": ["Other Talk remaining", TIME_MINUTES, "mdi:cellphone"],
}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py
index 4cd83e64a83..528d44bbb83 100644
--- a/homeassistant/components/file/notify.py
+++ b/homeassistant/components/file/notify.py
@@ -46,15 +46,11 @@ class FileNotificationService(BaseNotificationService):
"""Send a message to a file."""
with open(self.filepath, "a") as file:
if os.stat(self.filepath).st_size == 0:
- title = "{} notifications (Log started: {})\n{}\n".format(
- kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT),
- dt_util.utcnow().isoformat(),
- "-" * 80,
- )
+ title = f"{kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)} notifications (Log started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n"
file.write(title)
if self.add_timestamp:
- text = "{} {}\n".format(dt_util.utcnow().isoformat(), message)
+ text = f"{dt_util.utcnow().isoformat()} {message}\n"
else:
text = f"{message}\n"
file.write(text)
diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py
index 77622f62b1d..4d508ce2d81 100644
--- a/homeassistant/components/filter/sensor.py
+++ b/homeassistant/components/filter/sensor.py
@@ -364,6 +364,7 @@ class Filter:
self._skip_processing = False
self._window_size = window_size
self._store_raw = False
+ self._only_numbers = True
@property
def window_size(self):
@@ -386,7 +387,11 @@ class Filter:
def filter_state(self, new_state):
"""Implement a common interface for filters."""
- filtered = self._filter_state(FilterState(new_state))
+ fstate = FilterState(new_state)
+ if self._only_numbers and not isinstance(fstate.state, Number):
+ raise ValueError
+
+ filtered = self._filter_state(fstate)
filtered.set_precision(self.precision)
if self._store_raw:
self.states.append(copy(FilterState(new_state)))
@@ -423,6 +428,7 @@ class RangeFilter(Filter):
def _filter_state(self, new_state):
"""Implement the range filter."""
+
if self._upper_bound is not None and new_state.state > self._upper_bound:
self._stats_internal["erasures_up"] += 1
@@ -469,6 +475,7 @@ class OutlierFilter(Filter):
def _filter_state(self, new_state):
"""Implement the outlier filter."""
+
median = statistics.median([s.state for s in self.states]) if self.states else 0
if (
len(self.states) == self.states.maxlen
@@ -498,6 +505,7 @@ class LowPassFilter(Filter):
def _filter_state(self, new_state):
"""Implement the low pass filter."""
+
if not self.states:
return new_state
@@ -539,6 +547,7 @@ class TimeSMAFilter(Filter):
def _filter_state(self, new_state):
"""Implement the Simple Moving Average filter."""
+
self._leak(new_state.timestamp)
self.queue.append(copy(new_state))
@@ -565,6 +574,7 @@ class ThrottleFilter(Filter):
def __init__(self, window_size, precision, entity):
"""Initialize Filter."""
super().__init__(FILTER_NAME_THROTTLE, window_size, precision, entity)
+ self._only_numbers = False
def _filter_state(self, new_state):
"""Implement the throttle filter."""
@@ -589,6 +599,7 @@ class TimeThrottleFilter(Filter):
super().__init__(FILTER_NAME_TIME_THROTTLE, window_size, precision, entity)
self._time_window = window_size
self._last_emitted_at = None
+ self._only_numbers = False
def _filter_state(self, new_state):
"""Implement the filter."""
diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py
index 5ddb63ef899..66c283f20ef 100644
--- a/homeassistant/components/fitbit/sensor.py
+++ b/homeassistant/components/fitbit/sensor.py
@@ -11,7 +11,15 @@ import voluptuous as vol
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import ATTR_ATTRIBUTION, CONF_UNIT_SYSTEM
+from homeassistant.const import (
+ ATTR_ATTRIBUTION,
+ CONF_UNIT_SYSTEM,
+ MASS_KILOGRAMS,
+ MASS_MILLIGRAMS,
+ TIME_MILLISECONDS,
+ TIME_MINUTES,
+ UNIT_PERCENTAGE,
+)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -48,14 +56,14 @@ FITBIT_RESOURCES_LIST = {
"activities/elevation": ["Elevation", "", "walk"],
"activities/floors": ["Floors", "floors", "walk"],
"activities/heart": ["Resting Heart Rate", "bpm", "heart-pulse"],
- "activities/minutesFairlyActive": ["Minutes Fairly Active", "minutes", "walk"],
- "activities/minutesLightlyActive": ["Minutes Lightly Active", "minutes", "walk"],
+ "activities/minutesFairlyActive": ["Minutes Fairly Active", TIME_MINUTES, "walk"],
+ "activities/minutesLightlyActive": ["Minutes Lightly Active", TIME_MINUTES, "walk"],
"activities/minutesSedentary": [
"Minutes Sedentary",
- "minutes",
+ TIME_MINUTES,
"seat-recline-normal",
],
- "activities/minutesVeryActive": ["Minutes Very Active", "minutes", "run"],
+ "activities/minutesVeryActive": ["Minutes Very Active", TIME_MINUTES, "run"],
"activities/steps": ["Steps", "steps", "walk"],
"activities/tracker/activityCalories": ["Tracker Activity Calories", "cal", "fire"],
"activities/tracker/calories": ["Tracker Calories", "cal", "fire"],
@@ -64,53 +72,57 @@ FITBIT_RESOURCES_LIST = {
"activities/tracker/floors": ["Tracker Floors", "floors", "walk"],
"activities/tracker/minutesFairlyActive": [
"Tracker Minutes Fairly Active",
- "minutes",
+ TIME_MINUTES,
"walk",
],
"activities/tracker/minutesLightlyActive": [
"Tracker Minutes Lightly Active",
- "minutes",
+ TIME_MINUTES,
"walk",
],
"activities/tracker/minutesSedentary": [
"Tracker Minutes Sedentary",
- "minutes",
+ TIME_MINUTES,
"seat-recline-normal",
],
"activities/tracker/minutesVeryActive": [
"Tracker Minutes Very Active",
- "minutes",
+ TIME_MINUTES,
"run",
],
"activities/tracker/steps": ["Tracker Steps", "steps", "walk"],
"body/bmi": ["BMI", "BMI", "human"],
- "body/fat": ["Body Fat", "%", "human"],
+ "body/fat": ["Body Fat", UNIT_PERCENTAGE, "human"],
"body/weight": ["Weight", "", "human"],
"devices/battery": ["Battery", None, None],
"sleep/awakeningsCount": ["Awakenings Count", "times awaken", "sleep"],
- "sleep/efficiency": ["Sleep Efficiency", "%", "sleep"],
- "sleep/minutesAfterWakeup": ["Minutes After Wakeup", "minutes", "sleep"],
- "sleep/minutesAsleep": ["Sleep Minutes Asleep", "minutes", "sleep"],
- "sleep/minutesAwake": ["Sleep Minutes Awake", "minutes", "sleep"],
- "sleep/minutesToFallAsleep": ["Sleep Minutes to Fall Asleep", "minutes", "sleep"],
+ "sleep/efficiency": ["Sleep Efficiency", UNIT_PERCENTAGE, "sleep"],
+ "sleep/minutesAfterWakeup": ["Minutes After Wakeup", TIME_MINUTES, "sleep"],
+ "sleep/minutesAsleep": ["Sleep Minutes Asleep", TIME_MINUTES, "sleep"],
+ "sleep/minutesAwake": ["Sleep Minutes Awake", TIME_MINUTES, "sleep"],
+ "sleep/minutesToFallAsleep": [
+ "Sleep Minutes to Fall Asleep",
+ TIME_MINUTES,
+ "sleep",
+ ],
"sleep/startTime": ["Sleep Start Time", None, "clock"],
- "sleep/timeInBed": ["Sleep Time in Bed", "minutes", "hotel"],
+ "sleep/timeInBed": ["Sleep Time in Bed", TIME_MINUTES, "hotel"],
}
FITBIT_MEASUREMENTS = {
"en_US": {
- "duration": "ms",
+ "duration": TIME_MILLISECONDS,
"distance": "mi",
"elevation": "ft",
"height": "in",
"weight": "lbs",
"body": "in",
"liquids": "fl. oz.",
- "blood glucose": "mg/dL",
+ "blood glucose": f"{MASS_MILLIGRAMS}/dL",
"battery": "",
},
"en_GB": {
- "duration": "milliseconds",
+ "duration": TIME_MILLISECONDS,
"distance": "kilometers",
"elevation": "meters",
"height": "centimeters",
@@ -121,11 +133,11 @@ FITBIT_MEASUREMENTS = {
"battery": "",
},
"metric": {
- "duration": "milliseconds",
+ "duration": TIME_MILLISECONDS,
"distance": "kilometers",
"elevation": "meters",
"height": "centimeters",
- "weight": "kilograms",
+ "weight": MASS_KILOGRAMS,
"body": "centimeters",
"liquids": "milliliters",
"blood glucose": "mmol/L",
@@ -170,16 +182,14 @@ def request_app_setup(hass, config, add_entities, config_path, discovery_info=No
start_url = f"{hass.config.api.base_url}{FITBIT_AUTH_CALLBACK_PATH}"
- description = """Please create a Fitbit developer app at
+ description = f"""Please create a Fitbit developer app at
https://dev.fitbit.com/apps/new.
For the OAuth 2.0 Application Type choose Personal.
- Set the Callback URL to {}.
+ Set the Callback URL to {start_url}.
They will provide you a Client ID and secret.
- These need to be saved into the file located at: {}.
+ These need to be saved into the file located at: {config_path}.
Then come back here and hit the below button.
- """.format(
- start_url, config_path
- )
+ """
submit = "I have saved my Client ID and Client Secret into fitbit.conf."
@@ -297,9 +307,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
config_file.get(ATTR_CLIENT_ID), config_file.get(ATTR_CLIENT_SECRET)
)
- redirect_uri = "{}{}".format(
- hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH
- )
+ redirect_uri = f"{hass.config.api.base_url}{FITBIT_AUTH_CALLBACK_PATH}"
fitbit_auth_start_url, _ = oauth.authorize_token_url(
redirect_uri=redirect_uri,
@@ -344,26 +352,20 @@ class FitbitAuthCallbackView(HomeAssistantView):
result = None
if data.get("code") is not None:
- redirect_uri = "{}{}".format(
- hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH
- )
+ redirect_uri = f"{hass.config.api.base_url}{FITBIT_AUTH_CALLBACK_PATH}"
try:
result = self.oauth.fetch_access_token(data.get("code"), redirect_uri)
except MissingTokenError as error:
_LOGGER.error("Missing token: %s", error)
- response_message = """Something went wrong when
+ response_message = f"""Something went wrong when
attempting authenticating with Fitbit. The error
- encountered was {}. Please try again!""".format(
- error
- )
+ encountered was {error}. Please try again!"""
except MismatchingStateError as error:
_LOGGER.error("Mismatched state, CSRF error: %s", error)
- response_message = """Something went wrong when
+ response_message = f"""Something went wrong when
attempting authenticating with Fitbit. The error
- encountered was {}. Please try again!""".format(
- error
- )
+ encountered was {error}. Please try again!"""
else:
_LOGGER.error("Unknown error when authing")
response_message = """Something went wrong when
@@ -378,10 +380,8 @@ class FitbitAuthCallbackView(HomeAssistantView):
An unknown error occurred. Please try again!
"""
- html_response = """Fitbit Auth
- {}
""".format(
- response_message
- )
+ html_response = f"""Fitbit Auth
+ {response_message}
"""
if result:
config_contents = {
@@ -413,7 +413,7 @@ class FitbitSensor(Entity):
self.extra = extra
self._name = FITBIT_RESOURCES_LIST[self.resource_type][0]
if self.extra:
- self._name = "{0} Battery".format(self.extra.get("deviceVersion"))
+ self._name = f"{self.extra.get('deviceVersion')} Battery"
unit_type = FITBIT_RESOURCES_LIST[self.resource_type][1]
if unit_type == "":
split_resource = self.resource_type.split("/")
@@ -449,7 +449,7 @@ class FitbitSensor(Entity):
if self.resource_type == "devices/battery" and self.extra:
battery_level = BATTERY_LEVELS[self.extra.get("battery")]
return icon_for_battery_level(battery_level=battery_level, charging=None)
- return "mdi:{}".format(FITBIT_RESOURCES_LIST[self.resource_type][2])
+ return f"mdi:{FITBIT_RESOURCES_LIST[self.resource_type][2]}"
@property
def device_state_attributes(self):
@@ -502,7 +502,7 @@ class FitbitSensor(Entity):
self._state = raw_state
else:
try:
- self._state = "{0:,}".format(int(raw_state))
+ self._state = f"{int(raw_state):,}"
except TypeError:
self._state = raw_state
diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py
index 34ddd9a8ffa..8720f67f396 100644
--- a/homeassistant/components/flexit/climate.py
+++ b/homeassistant/components/flexit/climate.py
@@ -1,16 +1,4 @@
-"""
-Platform for Flexit AC units with CI66 Modbus adapter.
-
-Example configuration:
-
-climate:
- - platform: flexit
- name: Main AC
- slave: 21
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/climate.flexit/
-"""
+"""Platform for Flexit AC units with CI66 Modbus adapter."""
import logging
from typing import List
diff --git a/homeassistant/components/flic/binary_sensor.py b/homeassistant/components/flic/binary_sensor.py
index 4f2f229977f..55f92e2e5ce 100644
--- a/homeassistant/components/flic/binary_sensor.py
+++ b/homeassistant/components/flic/binary_sensor.py
@@ -168,7 +168,7 @@ class FlicButton(BinarySensorDevice):
@property
def name(self):
"""Return the name of the device."""
- return "flic_{}".format(self.address.replace(":", ""))
+ return f"flic_{self.address.replace(':', '')}"
@property
def address(self):
@@ -192,9 +192,7 @@ class FlicButton(BinarySensorDevice):
def _queued_event_check(self, click_type, time_diff):
"""Generate a log message and returns true if timeout exceeded."""
- time_string = "{:d} {}".format(
- time_diff, "second" if time_diff == 1 else "seconds"
- )
+ time_string = f"{time_diff:d} {'second' if time_diff == 1 else 'seconds'}"
if time_diff > self._timeout:
_LOGGER.warning(
diff --git a/homeassistant/components/flume/manifest.json b/homeassistant/components/flume/manifest.json
index d03c6330f20..2264df2db06 100644
--- a/homeassistant/components/flume/manifest.json
+++ b/homeassistant/components/flume/manifest.json
@@ -2,7 +2,7 @@
"domain": "flume",
"name": "flume",
"documentation": "https://www.home-assistant.io/integrations/flume/",
- "requirements": ["pyflume==0.2.4"],
+ "requirements": ["pyflume==0.3.0"],
"dependencies": [],
"codeowners": ["@ChrisMandich"]
}
diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py
index e96ce0d96ef..2694842134f 100644
--- a/homeassistant/components/flume/sensor.py
+++ b/homeassistant/components/flume/sensor.py
@@ -3,6 +3,7 @@ from datetime import timedelta
import logging
from pyflume import FlumeData, FlumeDeviceList
+from requests import Session
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
@@ -42,23 +43,37 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
name = config[CONF_NAME]
flume_entity_list = []
+ http_session = Session()
+
flume_devices = FlumeDeviceList(
- username, password, client_id, client_secret, flume_token_file
+ username,
+ password,
+ client_id,
+ client_secret,
+ flume_token_file,
+ http_session=http_session,
)
for device in flume_devices.device_list:
if device["type"] == FLUME_TYPE_SENSOR:
+ device_id = device["id"]
+ device_name = device["location"]["name"]
+
flume = FlumeData(
username,
password,
client_id,
client_secret,
- device["id"],
+ device_id,
time_zone,
SCAN_INTERVAL,
flume_token_file,
+ update_on_init=False,
+ http_session=http_session,
+ )
+ flume_entity_list.append(
+ FlumeSensor(flume, f"{name} {device_name}", device_id)
)
- flume_entity_list.append(FlumeSensor(flume, f"{name} {device['id']}"))
if flume_entity_list:
add_entities(flume_entity_list, True)
@@ -67,11 +82,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
class FlumeSensor(Entity):
"""Representation of the Flume sensor."""
- def __init__(self, flume, name):
+ def __init__(self, flume, name, device_id):
"""Initialize the Flume sensor."""
self.flume = flume
self._name = name
+ self._device_id = device_id
self._state = None
+ self._available = False
@property
def name(self):
@@ -86,9 +103,24 @@ class FlumeSensor(Entity):
@property
def unit_of_measurement(self):
"""Return the unit the value is expressed in."""
- return "gal"
+ # This is in gallons per SCAN_INTERVAL
+ return "gal/m"
+
+ @property
+ def available(self):
+ """Device is available."""
+ return self._available
+
+ @property
+ def unique_id(self):
+ """Device unique ID."""
+ return self._device_id
def update(self):
"""Get the latest data and updates the states."""
+ self._available = False
self.flume.update()
- self._state = self.flume.value
+ new_value = self.flume.value
+ if new_value is not None:
+ self._available = True
+ self._state = new_value
diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py
index f22b6335911..0205bb308be 100644
--- a/homeassistant/components/flux/switch.py
+++ b/homeassistant/components/flux/switch.py
@@ -2,9 +2,6 @@
Flux for Home-Assistant.
The idea was taken from https://github.com/KpaBap/hue-flux/
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/switch.flux/
"""
import datetime
import logging
@@ -167,7 +164,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
"""Update lights."""
await flux.async_flux_update()
- service_name = slugify("{} {}".format(name, "update"))
+ service_name = slugify(f"{name} update")
hass.services.async_register(DOMAIN, service_name, async_update)
diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py
index 16db60abbc0..88b8c91420d 100644
--- a/homeassistant/components/flux_led/light.py
+++ b/homeassistant/components/flux_led/light.py
@@ -167,7 +167,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
ipaddr = device["ipaddr"]
if ipaddr in light_ips:
continue
- device["name"] = "{} {}".format(device["id"], ipaddr)
+ device["name"] = f"{device['id']} {ipaddr}"
device[ATTR_MODE] = None
device[CONF_PROTOCOL] = None
device[CONF_CUSTOM_EFFECT] = None
diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py
index efb74e2cc9a..e0322ccbab7 100644
--- a/homeassistant/components/foobot/sensor.py
+++ b/homeassistant/components/foobot/sensor.py
@@ -10,9 +10,14 @@ import voluptuous as vol
from homeassistant.const import (
ATTR_TEMPERATURE,
ATTR_TIME,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_PARTS_PER_MILLION,
CONF_TOKEN,
CONF_USERNAME,
TEMP_CELSIUS,
+ TIME_SECONDS,
+ UNIT_PERCENTAGE,
)
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -30,13 +35,21 @@ ATTR_VOLATILE_ORGANIC_COMPOUNDS = "VOC"
ATTR_FOOBOT_INDEX = "index"
SENSOR_TYPES = {
- "time": [ATTR_TIME, "s"],
- "pm": [ATTR_PM2_5, "µg/m3", "mdi:cloud"],
+ "time": [ATTR_TIME, TIME_SECONDS],
+ "pm": [ATTR_PM2_5, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, "mdi:cloud"],
"tmp": [ATTR_TEMPERATURE, TEMP_CELSIUS, "mdi:thermometer"],
- "hum": [ATTR_HUMIDITY, "%", "mdi:water-percent"],
- "co2": [ATTR_CARBON_DIOXIDE, "ppm", "mdi:periodic-table-co2"],
- "voc": [ATTR_VOLATILE_ORGANIC_COMPOUNDS, "ppb", "mdi:cloud"],
- "allpollu": [ATTR_FOOBOT_INDEX, "%", "mdi:percent"],
+ "hum": [ATTR_HUMIDITY, UNIT_PERCENTAGE, "mdi:water-percent"],
+ "co2": [
+ ATTR_CARBON_DIOXIDE,
+ CONCENTRATION_PARTS_PER_MILLION,
+ "mdi:periodic-table-co2",
+ ],
+ "voc": [
+ ATTR_VOLATILE_ORGANIC_COMPOUNDS,
+ CONCENTRATION_PARTS_PER_BILLION,
+ "mdi:cloud",
+ ],
+ "allpollu": [ATTR_FOOBOT_INDEX, UNIT_PERCENTAGE, "mdi:percent"],
}
SCAN_INTERVAL = timedelta(minutes=10)
@@ -89,7 +102,7 @@ class FoobotSensor(Entity):
"""Initialize the sensor."""
self._uuid = device["uuid"]
self.foobot_data = data
- self._name = "Foobot {} {}".format(device["name"], SENSOR_TYPES[sensor_type][0])
+ self._name = f"Foobot {device['name']} {SENSOR_TYPES[sensor_type][0]}"
self.type = sensor_type
self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py
index f4ec6556894..1c4c6bb9c8c 100644
--- a/homeassistant/components/foscam/camera.py
+++ b/homeassistant/components/foscam/camera.py
@@ -190,12 +190,7 @@ class HassFoscamCamera(Camera):
async def stream_source(self):
"""Return the stream source."""
if self._rtsp_port:
- return "rtsp://{}:{}@{}:{}/videoMain".format(
- self._username,
- self._password,
- self._foscam_session.host,
- self._rtsp_port,
- )
+ return f"rtsp://{self._username}:{self._password}@{self._foscam_session.host}:{self._rtsp_port}/videoMain"
return None
@property
diff --git a/homeassistant/components/foursquare/__init__.py b/homeassistant/components/foursquare/__init__.py
index af15c4e5fa8..07d177ebf30 100644
--- a/homeassistant/components/foursquare/__init__.py
+++ b/homeassistant/components/foursquare/__init__.py
@@ -52,12 +52,7 @@ def setup(hass, config):
def checkin_user(call):
"""Check a user in on Swarm."""
- url = (
- "https://api.foursquare.com/v2/checkins/add"
- "?oauth_token={}"
- "&v=20160802"
- "&m=swarm"
- ).format(config[CONF_ACCESS_TOKEN])
+ url = f"https://api.foursquare.com/v2/checkins/add?oauth_token={config[CONF_ACCESS_TOKEN]}&v=20160802&m=swarm"
response = requests.post(url, data=call.data, timeout=10)
if response.status_code not in (200, 201):
diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py
index c51c952ab06..5b87d6e726a 100644
--- a/homeassistant/components/fritzbox/switch.py
+++ b/homeassistant/components/fritzbox/switch.py
@@ -78,9 +78,9 @@ class FritzboxSwitch(SwitchDevice):
attrs[ATTR_STATE_LOCKED] = self._device.lock
if self._device.has_powermeter:
- attrs[ATTR_TOTAL_CONSUMPTION] = "{:.3f}".format(
- (self._device.energy or 0.0) / 1000
- )
+ attrs[
+ ATTR_TOTAL_CONSUMPTION
+ ] = f"{((self._device.energy or 0.0) / 1000):.3f}"
attrs[ATTR_TOTAL_CONSUMPTION_UNIT] = ATTR_TOTAL_CONSUMPTION_UNIT_VALUE
if self._device.has_temperature_sensor:
attrs[ATTR_TEMPERATURE] = str(
diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py
index 27e2531c9f9..722dc2dc659 100644
--- a/homeassistant/components/fronius/sensor.py
+++ b/homeassistant/components/fronius/sensor.py
@@ -90,11 +90,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
device = condition[CONF_DEVICE]
sensor_type = condition[CONF_SENSOR_TYPE]
scope = condition[CONF_SCOPE]
- name = "Fronius {} {} {}".format(
- condition[CONF_SENSOR_TYPE].replace("_", " ").capitalize(),
- device if scope == SCOPE_DEVICE else SCOPE_SYSTEM,
- config[CONF_RESOURCE],
- )
+ name = f"Fronius {condition[CONF_SENSOR_TYPE].replace('_', ' ').capitalize()} {device if scope == SCOPE_DEVICE else SCOPE_SYSTEM} {config[CONF_RESOURCE]}"
if sensor_type == TYPE_INVERTER:
if scope == SCOPE_SYSTEM:
adapter_cls = FroniusInverterSystem
@@ -258,9 +254,7 @@ class FroniusTemplateSensor(Entity):
@property
def name(self):
"""Return the name of the sensor."""
- return "{} {}".format(
- self._name.replace("_", " ").capitalize(), self.parent.name
- )
+ return f"{self._name.replace('_', ' ').capitalize()} {self.parent.name}"
@property
def state(self):
diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py
index a6f531b6dd5..d9a39ce5726 100644
--- a/homeassistant/components/frontend/__init__.py
+++ b/homeassistant/components/frontend/__init__.py
@@ -50,8 +50,8 @@ MANIFEST_JSON = {
"display": "standalone",
"icons": [
{
- "src": "/static/icons/favicon-{size}x{size}.png".format(size=size),
- "sizes": "{size}x{size}".format(size=size),
+ "src": f"/static/icons/favicon-{size}x{size}.png",
+ "sizes": f"{size}x{size}",
"type": "image/png",
"purpose": "maskable any",
}
@@ -171,6 +171,8 @@ def async_register_built_in_panel(
frontend_url_path=None,
config=None,
require_admin=False,
+ *,
+ update=False,
):
"""Register a built-in panel."""
panel = Panel(
@@ -184,8 +186,8 @@ def async_register_built_in_panel(
panels = hass.data.setdefault(DATA_PANELS, {})
- if panel.frontend_url_path in panels:
- _LOGGER.warning("Overwriting integration %s", panel.frontend_url_path)
+ if not update and panel.frontend_url_path in panels:
+ raise ValueError(f"Overwriting panel {panel.frontend_url_path}")
panels[panel.frontend_url_path] = panel
@@ -274,8 +276,7 @@ async def async_setup(hass, config):
hass.http.app.router.register_resource(IndexView(repo_path, hass))
- for panel in ("kiosk", "states", "profile"):
- async_register_built_in_panel(hass, panel)
+ async_register_built_in_panel(hass, "profile")
# To smooth transition to new urls, add redirects to new urls of dev tools
# Added June 27, 2019. Can be removed in 2021.
diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json
index b957ef13895..fc9cd188565 100644
--- a/homeassistant/components/frontend/manifest.json
+++ b/homeassistant/components/frontend/manifest.json
@@ -3,7 +3,7 @@
"name": "Home Assistant Frontend",
"documentation": "https://www.home-assistant.io/integrations/frontend",
"requirements": [
- "home-assistant-frontend==20200220.5"
+ "home-assistant-frontend==20200318.0"
],
"dependencies": [
"api",
diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py
index 2f68c5f8e01..b37945b5e07 100644
--- a/homeassistant/components/frontend/storage.py
+++ b/homeassistant/components/frontend/storage.py
@@ -9,7 +9,6 @@ from homeassistant.components import websocket_api
DATA_STORAGE = "frontend_storage"
STORAGE_VERSION_USER_DATA = 1
-STORAGE_KEY_USER_DATA = "frontend.user_data_{}"
async def async_setup_frontend_storage(hass):
@@ -31,8 +30,7 @@ def with_store(orig_func):
if store is None:
store = stores[user_id] = hass.helpers.storage.Store(
- STORAGE_VERSION_USER_DATA,
- STORAGE_KEY_USER_DATA.format(connection.user.id),
+ STORAGE_VERSION_USER_DATA, f"frontend.user_data_{connection.user.id}"
)
if user_id not in data:
diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py
index 627c3c079b9..93e96d6e967 100644
--- a/homeassistant/components/frontier_silicon/media_player.py
+++ b/homeassistant/components/frontier_silicon/media_player.py
@@ -24,8 +24,10 @@ from homeassistant.components.media_player.const import (
)
from homeassistant.const import (
CONF_HOST,
+ CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
+ STATE_IDLE,
STATE_OFF,
STATE_PAUSED,
STATE_PLAYING,
@@ -53,13 +55,13 @@ SUPPORT_FRONTIER_SILICON = (
DEFAULT_PORT = 80
DEFAULT_PASSWORD = "1234"
-DEVICE_URL = "http://{0}:{1}/device"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
}
)
@@ -68,17 +70,19 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
"""Set up the Frontier Silicon platform."""
if discovery_info is not None:
async_add_entities(
- [AFSAPIDevice(discovery_info["ssdp_description"], DEFAULT_PASSWORD)], True
+ [AFSAPIDevice(discovery_info["ssdp_description"], DEFAULT_PASSWORD, None)],
+ True,
)
return True
host = config.get(CONF_HOST)
port = config.get(CONF_PORT)
password = config.get(CONF_PASSWORD)
+ name = config.get(CONF_NAME)
try:
async_add_entities(
- [AFSAPIDevice(DEVICE_URL.format(host, port), password)], True
+ [AFSAPIDevice(f"http://{host}:{port}/device", password, name)], True
)
_LOGGER.debug("FSAPI device %s:%s -> %s", host, port, password)
return True
@@ -93,13 +97,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
class AFSAPIDevice(MediaPlayerDevice):
"""Representation of a Frontier Silicon device on the network."""
- def __init__(self, device_url, password):
+ def __init__(self, device_url, password, name):
"""Initialize the Frontier Silicon API device."""
self._device_url = device_url
self._password = password
self._state = None
- self._name = None
+ self._name = name
self._title = None
self._artist = None
self._album_name = None
@@ -107,6 +111,8 @@ class AFSAPIDevice(MediaPlayerDevice):
self._source = None
self._source_list = None
self._media_image_url = None
+ self._max_volume = None
+ self._volume_level = None
# Properties
@property
@@ -176,6 +182,11 @@ class AFSAPIDevice(MediaPlayerDevice):
"""Image url of current playing media."""
return self._media_image_url
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume_level
+
async def async_update(self):
"""Get the latest date and update device state."""
fs_device = self.fs_device
@@ -186,14 +197,23 @@ class AFSAPIDevice(MediaPlayerDevice):
if not self._source_list:
self._source_list = await fs_device.get_mode_list()
- status = await fs_device.get_play_status()
- self._state = {
- "playing": STATE_PLAYING,
- "paused": STATE_PAUSED,
- "stopped": STATE_OFF,
- "unknown": STATE_UNKNOWN,
- None: STATE_OFF,
- }.get(status, STATE_UNKNOWN)
+ # The API seems to include 'zero' in the number of steps (e.g. if the range is
+ # 0-40 then get_volume_steps returns 41) subtract one to get the max volume.
+ # If call to get_volume fails set to 0 and try again next time.
+ if not self._max_volume:
+ self._max_volume = int(await fs_device.get_volume_steps() or 1) - 1
+
+ if await fs_device.get_power():
+ status = await fs_device.get_play_status()
+ self._state = {
+ "playing": STATE_PLAYING,
+ "paused": STATE_PAUSED,
+ "stopped": STATE_IDLE,
+ "unknown": STATE_UNKNOWN,
+ None: STATE_IDLE,
+ }.get(status, STATE_UNKNOWN)
+ else:
+ self._state = STATE_OFF
if self._state != STATE_OFF:
info_name = await fs_device.get_play_name()
@@ -206,6 +226,11 @@ class AFSAPIDevice(MediaPlayerDevice):
self._source = await fs_device.get_mode()
self._mute = await fs_device.get_mute()
self._media_image_url = await fs_device.get_play_graphic()
+
+ volume = await self.fs_device.get_volume()
+
+ # Prevent division by zero if max_volume not known yet
+ self._volume_level = float(volume or 0) / (self._max_volume or 1)
else:
self._title = None
self._artist = None
@@ -215,6 +240,8 @@ class AFSAPIDevice(MediaPlayerDevice):
self._mute = None
self._media_image_url = None
+ self._volume_level = None
+
# Management actions
# power control
async def async_turn_on(self):
@@ -266,16 +293,20 @@ class AFSAPIDevice(MediaPlayerDevice):
async def async_volume_up(self):
"""Send volume up command."""
volume = await self.fs_device.get_volume()
- await self.fs_device.set_volume(volume + 1)
+ volume = int(volume or 0) + 1
+ await self.fs_device.set_volume(min(volume, self._max_volume))
async def async_volume_down(self):
"""Send volume down command."""
volume = await self.fs_device.get_volume()
- await self.fs_device.set_volume(volume - 1)
+ volume = int(volume or 0) - 1
+ await self.fs_device.set_volume(max(volume, 0))
async def async_set_volume_level(self, volume):
"""Set volume command."""
- await self.fs_device.set_volume(int(volume * 20))
+ if self._max_volume: # Can't do anything sensible if not set
+ volume = int(volume * self._max_volume)
+ await self.fs_device.set_volume(volume)
async def async_select_source(self, source):
"""Select input source."""
diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py
index 0eeb5f2b8f9..5a43c3c7281 100644
--- a/homeassistant/components/garadget/cover.py
+++ b/homeassistant/components/garadget/cover.py
@@ -251,9 +251,7 @@ class GaradgetCover(CoverDevice):
def _get_variable(self, var):
"""Get latest status."""
- url = "{}/v1/devices/{}/{}?access_token={}".format(
- self.particle_url, self.device_id, var, self.access_token
- )
+ url = f"{self.particle_url}/v1/devices/{self.device_id}/{var}?access_token={self.access_token}"
ret = requests.get(url, timeout=10)
result = {}
for pairs in ret.json()["result"].split("|"):
diff --git a/homeassistant/components/garmin_connect/.translations/lv.json b/homeassistant/components/garmin_connect/.translations/lv.json
new file mode 100644
index 00000000000..2c205bdd324
--- /dev/null
+++ b/homeassistant/components/garmin_connect/.translations/lv.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "Parole",
+ "username": "Lietot\u0101jv\u0101rds"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/garmin_connect/const.py b/homeassistant/components/garmin_connect/const.py
index b5faeab77b4..38245ff5eb8 100644
--- a/homeassistant/components/garmin_connect/const.py
+++ b/homeassistant/components/garmin_connect/const.py
@@ -1,5 +1,5 @@
"""Constants for the Garmin Connect integration."""
-from homeassistant.const import DEVICE_CLASS_TIMESTAMP
+from homeassistant.const import DEVICE_CLASS_TIMESTAMP, TIME_MINUTES, UNIT_PERCENTAGE
DOMAIN = "garmin_connect"
ATTRIBUTION = "Data provided by garmin.com"
@@ -52,20 +52,32 @@ GARMIN_ENTITY_LIST = {
False,
],
"wellnessKilocalories": ["Wellness KiloCalories", "kcal", "mdi:food", None, False],
- "highlyActiveSeconds": ["Highly Active Time", "min", "mdi:fire", None, False],
- "activeSeconds": ["Active Time", "min", "mdi:fire", None, True],
- "sedentarySeconds": ["Sedentary Time", "min", "mdi:seat", None, True],
- "sleepingSeconds": ["Sleeping Time", "min", "mdi:sleep", None, True],
- "measurableAwakeDuration": ["Awake Duration", "min", "mdi:sleep", None, True],
- "measurableAsleepDuration": ["Sleep Duration", "min", "mdi:sleep", None, True],
- "floorsAscendedInMeters": ["Floors Ascended Mtr", "m", "mdi:stairs", None, False],
- "floorsDescendedInMeters": [
- "Floors Descended Mtr",
- "m",
- "mdi:stairs",
+ "highlyActiveSeconds": [
+ "Highly Active Time",
+ TIME_MINUTES,
+ "mdi:fire",
None,
False,
],
+ "activeSeconds": ["Active Time", TIME_MINUTES, "mdi:fire", None, True],
+ "sedentarySeconds": ["Sedentary Time", TIME_MINUTES, "mdi:seat", None, True],
+ "sleepingSeconds": ["Sleeping Time", TIME_MINUTES, "mdi:sleep", None, True],
+ "measurableAwakeDuration": [
+ "Awake Duration",
+ TIME_MINUTES,
+ "mdi:sleep",
+ None,
+ True,
+ ],
+ "measurableAsleepDuration": [
+ "Sleep Duration",
+ TIME_MINUTES,
+ "mdi:sleep",
+ None,
+ True,
+ ],
+ "floorsAscendedInMeters": ["Floors Ascended Mtr", "m", "mdi:stairs", None, False],
+ "floorsDescendedInMeters": ["Floors Descended Mtr", "m", "mdi:stairs", None, False],
"floorsAscended": ["Floors Ascended", "floors", "mdi:stairs", None, True],
"floorsDescended": ["Floors Descended", "floors", "mdi:stairs", None, True],
"userFloorsAscendedGoal": [
@@ -97,146 +109,164 @@ GARMIN_ENTITY_LIST = {
"averageStressLevel": ["Avg Stress Level", "", "mdi:flash-alert", None, True],
"maxStressLevel": ["Max Stress Level", "", "mdi:flash-alert", None, True],
"stressQualifier": ["Stress Qualifier", "", "mdi:flash-alert", None, False],
- "stressDuration": ["Stress Duration", "min", "mdi:flash-alert", None, False],
+ "stressDuration": ["Stress Duration", TIME_MINUTES, "mdi:flash-alert", None, False],
"restStressDuration": [
"Rest Stress Duration",
- "min",
+ TIME_MINUTES,
"mdi:flash-alert",
None,
True,
],
"activityStressDuration": [
"Activity Stress Duration",
- "min",
+ TIME_MINUTES,
"mdi:flash-alert",
None,
True,
],
"uncategorizedStressDuration": [
"Uncat. Stress Duration",
- "min",
+ TIME_MINUTES,
"mdi:flash-alert",
None,
True,
],
"totalStressDuration": [
"Total Stress Duration",
- "min",
+ TIME_MINUTES,
+ "mdi:flash-alert",
+ None,
+ True,
+ ],
+ "lowStressDuration": [
+ "Low Stress Duration",
+ TIME_MINUTES,
"mdi:flash-alert",
None,
True,
],
- "lowStressDuration": ["Low Stress Duration", "min", "mdi:flash-alert", None, True],
"mediumStressDuration": [
"Medium Stress Duration",
- "min",
+ TIME_MINUTES,
"mdi:flash-alert",
None,
True,
],
"highStressDuration": [
"High Stress Duration",
- "min",
+ TIME_MINUTES,
"mdi:flash-alert",
None,
True,
],
- "stressPercentage": ["Stress Percentage", "%", "mdi:flash-alert", None, False],
+ "stressPercentage": [
+ "Stress Percentage",
+ UNIT_PERCENTAGE,
+ "mdi:flash-alert",
+ None,
+ False,
+ ],
"restStressPercentage": [
"Rest Stress Percentage",
- "%",
+ UNIT_PERCENTAGE,
"mdi:flash-alert",
None,
False,
],
"activityStressPercentage": [
"Activity Stress Percentage",
- "%",
+ UNIT_PERCENTAGE,
"mdi:flash-alert",
None,
False,
],
"uncategorizedStressPercentage": [
"Uncat. Stress Percentage",
- "%",
+ UNIT_PERCENTAGE,
"mdi:flash-alert",
None,
False,
],
"lowStressPercentage": [
"Low Stress Percentage",
- "%",
+ UNIT_PERCENTAGE,
"mdi:flash-alert",
None,
False,
],
"mediumStressPercentage": [
"Medium Stress Percentage",
- "%",
+ UNIT_PERCENTAGE,
"mdi:flash-alert",
None,
False,
],
"highStressPercentage": [
"High Stress Percentage",
- "%",
+ UNIT_PERCENTAGE,
"mdi:flash-alert",
None,
False,
],
"moderateIntensityMinutes": [
"Moderate Intensity",
- "min",
+ TIME_MINUTES,
"mdi:flash-alert",
None,
False,
],
"vigorousIntensityMinutes": [
"Vigorous Intensity",
- "min",
+ TIME_MINUTES,
+ "mdi:run-fast",
+ None,
+ False,
+ ],
+ "intensityMinutesGoal": [
+ "Intensity Goal",
+ TIME_MINUTES,
"mdi:run-fast",
None,
False,
],
- "intensityMinutesGoal": ["Intensity Goal", "min", "mdi:run-fast", None, False],
"bodyBatteryChargedValue": [
"Body Battery Charged",
- "%",
+ UNIT_PERCENTAGE,
"mdi:battery-charging-100",
None,
True,
],
"bodyBatteryDrainedValue": [
"Body Battery Drained",
- "%",
+ UNIT_PERCENTAGE,
"mdi:battery-alert-variant-outline",
None,
True,
],
"bodyBatteryHighestValue": [
"Body Battery Highest",
- "%",
+ UNIT_PERCENTAGE,
"mdi:battery-heart",
None,
True,
],
"bodyBatteryLowestValue": [
"Body Battery Lowest",
- "%",
+ UNIT_PERCENTAGE,
"mdi:battery-heart-outline",
None,
True,
],
"bodyBatteryMostRecentValue": [
"Body Battery Most Recent",
- "%",
+ UNIT_PERCENTAGE,
"mdi:battery-positive",
None,
True,
],
- "averageSpo2": ["Average SPO2", "%", "mdi:diabetes", None, True],
- "lowestSpo2": ["Lowest SPO2", "%", "mdi:diabetes", None, True],
- "latestSpo2": ["Latest SPO2", "%", "mdi:diabetes", None, True],
+ "averageSpo2": ["Average SPO2", UNIT_PERCENTAGE, "mdi:diabetes", None, True],
+ "lowestSpo2": ["Lowest SPO2", UNIT_PERCENTAGE, "mdi:diabetes", None, True],
+ "latestSpo2": ["Latest SPO2", UNIT_PERCENTAGE, "mdi:diabetes", None, True],
"latestSpo2ReadingTimeLocal": [
"Latest SPO2 Time",
"",
@@ -246,7 +276,7 @@ GARMIN_ENTITY_LIST = {
],
"averageMonitoringEnvironmentAltitude": [
"Average Altitude",
- "%",
+ UNIT_PERCENTAGE,
"mdi:image-filter-hdr",
None,
False,
diff --git a/homeassistant/components/gdacs/__init__.py b/homeassistant/components/gdacs/__init__.py
index 34f1bdc88d8..8144b7667ca 100644
--- a/homeassistant/components/gdacs/__init__.py
+++ b/homeassistant/components/gdacs/__init__.py
@@ -28,10 +28,6 @@ from .const import (
DOMAIN,
FEED,
PLATFORMS,
- SIGNAL_DELETE_ENTITY,
- SIGNAL_NEW_GEOLOCATION,
- SIGNAL_STATUS,
- SIGNAL_UPDATE_ENTITY,
VALID_CATEGORIES,
)
@@ -181,7 +177,7 @@ class GdacsFeedEntityManager:
@callback
def async_event_new_entity(self):
"""Return manager specific event to signal new entity."""
- return SIGNAL_NEW_GEOLOCATION.format(self._config_entry_id)
+ return f"gdacs_new_geolocation_{self._config_entry_id}"
def get_entry(self, external_id):
"""Get feed entry by external id."""
@@ -194,19 +190,23 @@ class GdacsFeedEntityManager:
async def _generate_entity(self, external_id):
"""Generate new entity."""
async_dispatcher_send(
- self._hass, self.async_event_new_entity(), self, external_id
+ self._hass,
+ self.async_event_new_entity(),
+ self,
+ self._config_entry.unique_id,
+ external_id,
)
async def _update_entity(self, external_id):
"""Update entity."""
- async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id))
+ async_dispatcher_send(self._hass, f"gdacs_update_{external_id}")
async def _remove_entity(self, external_id):
"""Remove entity."""
- async_dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id))
+ async_dispatcher_send(self._hass, f"gdacs_delete_{external_id}")
async def _status_update(self, status_info):
"""Propagate status update."""
_LOGGER.debug("Status update received: %s", status_info)
self._status_info = status_info
- async_dispatcher_send(self._hass, SIGNAL_STATUS.format(self._config_entry_id))
+ async_dispatcher_send(self._hass, f"gdacs_status_{self._config_entry_id}")
diff --git a/homeassistant/components/gdacs/const.py b/homeassistant/components/gdacs/const.py
index 4579304f30d..5d5c83f013e 100644
--- a/homeassistant/components/gdacs/const.py
+++ b/homeassistant/components/gdacs/const.py
@@ -15,11 +15,5 @@ DEFAULT_ICON = "mdi:alert"
DEFAULT_RADIUS = 500.0
DEFAULT_SCAN_INTERVAL = timedelta(minutes=5)
-SIGNAL_DELETE_ENTITY = "gdacs_delete_{}"
-SIGNAL_UPDATE_ENTITY = "gdacs_update_{}"
-SIGNAL_STATUS = "gdacs_status_{}"
-
-SIGNAL_NEW_GEOLOCATION = "gdacs_new_geolocation_{}"
-
# Fetch valid categories from integration library.
VALID_CATEGORIES = list(EVENT_TYPE_MAP.values())
diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py
index 34da104e093..31c3ba4138c 100644
--- a/homeassistant/components/gdacs/geo_location.py
+++ b/homeassistant/components/gdacs/geo_location.py
@@ -13,13 +13,7 @@ from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util.unit_system import IMPERIAL_SYSTEM
-from .const import (
- DEFAULT_ICON,
- DOMAIN,
- FEED,
- SIGNAL_DELETE_ENTITY,
- SIGNAL_UPDATE_ENTITY,
-)
+from .const import DEFAULT_ICON, DOMAIN, FEED
_LOGGER = logging.getLogger(__name__)
@@ -55,9 +49,9 @@ async def async_setup_entry(hass, entry, async_add_entities):
manager = hass.data[DOMAIN][FEED][entry.entry_id]
@callback
- def async_add_geolocation(feed_manager, external_id):
+ def async_add_geolocation(feed_manager, integration_id, external_id):
"""Add gelocation entity from feed."""
- new_entity = GdacsEvent(feed_manager, external_id)
+ new_entity = GdacsEvent(feed_manager, integration_id, external_id)
_LOGGER.debug("Adding geolocation %s", new_entity)
async_add_entities([new_entity], True)
@@ -75,9 +69,10 @@ async def async_setup_entry(hass, entry, async_add_entities):
class GdacsEvent(GeolocationEvent):
"""This represents an external event with GDACS feed data."""
- def __init__(self, feed_manager, external_id):
+ def __init__(self, feed_manager, integration_id, external_id):
"""Initialize entity with data from feed entry."""
self._feed_manager = feed_manager
+ self._integration_id = integration_id
self._external_id = external_id
self._title = None
self._distance = None
@@ -102,14 +97,10 @@ class GdacsEvent(GeolocationEvent):
async def async_added_to_hass(self):
"""Call when entity is added to hass."""
self._remove_signal_delete = async_dispatcher_connect(
- self.hass,
- SIGNAL_DELETE_ENTITY.format(self._external_id),
- self._delete_callback,
+ self.hass, f"gdacs_delete_{self._external_id}", self._delete_callback
)
self._remove_signal_update = async_dispatcher_connect(
- self.hass,
- SIGNAL_UPDATE_ENTITY.format(self._external_id),
- self._update_callback,
+ self.hass, f"gdacs_update_{self._external_id}", self._update_callback
)
async def async_will_remove_from_hass(self) -> None:
@@ -172,6 +163,11 @@ class GdacsEvent(GeolocationEvent):
self._vulnerability = round(self._vulnerability, 1)
self._version = feed_entry.version
+ @property
+ def unique_id(self) -> Optional[str]:
+ """Return a unique ID containing latitude/longitude and external id."""
+ return f"{self._integration_id}_{self._external_id}"
+
@property
def icon(self):
"""Return the icon to use in the frontend, if any."""
diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py
index e58090fd165..fbbb199499b 100644
--- a/homeassistant/components/gdacs/sensor.py
+++ b/homeassistant/components/gdacs/sensor.py
@@ -7,7 +7,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.util import dt
-from .const import DEFAULT_ICON, DOMAIN, FEED, SIGNAL_STATUS
+from .const import DEFAULT_ICON, DOMAIN, FEED
_LOGGER = logging.getLogger(__name__)
@@ -28,7 +28,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(hass, entry, async_add_entities):
"""Set up the GDACS Feed platform."""
manager = hass.data[DOMAIN][FEED][entry.entry_id]
- sensor = GdacsSensor(entry.entry_id, entry.title, manager)
+ sensor = GdacsSensor(entry.entry_id, entry.unique_id, entry.title, manager)
async_add_entities([sensor])
_LOGGER.debug("Sensor setup done")
@@ -36,9 +36,10 @@ async def async_setup_entry(hass, entry, async_add_entities):
class GdacsSensor(Entity):
"""This is a status sensor for the GDACS integration."""
- def __init__(self, config_entry_id, config_title, manager):
+ def __init__(self, config_entry_id, config_unique_id, config_title, manager):
"""Initialize entity."""
self._config_entry_id = config_entry_id
+ self._config_unique_id = config_unique_id
self._config_title = config_title
self._manager = manager
self._status = None
@@ -55,7 +56,7 @@ class GdacsSensor(Entity):
"""Call when entity is added to hass."""
self._remove_signal_status = async_dispatcher_connect(
self.hass,
- SIGNAL_STATUS.format(self._config_entry_id),
+ f"gdacs_status_{self._config_entry_id}",
self._update_status_callback,
)
_LOGGER.debug("Waiting for updates %s", self._config_entry_id)
@@ -107,6 +108,11 @@ class GdacsSensor(Entity):
"""Return the state of the sensor."""
return self._total
+ @property
+ def unique_id(self) -> Optional[str]:
+ """Return a unique ID containing latitude/longitude."""
+ return self._config_unique_id
+
@property
def name(self) -> Optional[str]:
"""Return the name of the entity."""
diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py
index 23ee049052c..8714ddcfbe6 100644
--- a/homeassistant/components/generic_thermostat/climate.py
+++ b/homeassistant/components/generic_thermostat/climate.py
@@ -335,7 +335,7 @@ class GenericThermostat(ClimateDevice, RestoreEntity):
@property
def min_temp(self):
"""Return the minimum temperature."""
- if self._min_temp:
+ if self._min_temp is not None:
return self._min_temp
# get default temp from super class
@@ -344,7 +344,7 @@ class GenericThermostat(ClimateDevice, RestoreEntity):
@property
def max_temp(self):
"""Return the maximum temperature."""
- if self._max_temp:
+ if self._max_temp is not None:
return self._max_temp
# Get default temp from super class
diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py
index bd73c700e65..196cba7212e 100644
--- a/homeassistant/components/geniushub/sensor.py
+++ b/homeassistant/components/geniushub/sensor.py
@@ -2,7 +2,7 @@
from datetime import timedelta
from typing import Any, Dict
-from homeassistant.const import DEVICE_CLASS_BATTERY
+from homeassistant.const import DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
import homeassistant.util.dt as dt_util
@@ -77,7 +77,7 @@ class GeniusBattery(GeniusDevice):
@property
def unit_of_measurement(self) -> str:
"""Return the unit of measurement of the sensor."""
- return "%"
+ return UNIT_PERCENTAGE
@property
def state(self) -> str:
diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py
index 2f881232495..3435fcc50cf 100644
--- a/homeassistant/components/geo_json_events/geo_location.py
+++ b/homeassistant/components/geo_json_events/geo_location.py
@@ -29,9 +29,6 @@ DEFAULT_UNIT_OF_MEASUREMENT = "km"
SCAN_INTERVAL = timedelta(minutes=5)
-SIGNAL_DELETE_ENTITY = "geo_json_events_delete_{}"
-SIGNAL_UPDATE_ENTITY = "geo_json_events_update_{}"
-
SOURCE = "geo_json_events"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@@ -108,11 +105,11 @@ class GeoJsonFeedEntityManager:
def _update_entity(self, external_id):
"""Update entity."""
- dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id))
+ dispatcher_send(self._hass, f"geo_json_events_update_{external_id}")
def _remove_entity(self, external_id):
"""Remove entity."""
- dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id))
+ dispatcher_send(self._hass, f"geo_json_events_delete_{external_id}")
class GeoJsonLocationEvent(GeolocationEvent):
@@ -133,12 +130,12 @@ class GeoJsonLocationEvent(GeolocationEvent):
"""Call when entity is added to hass."""
self._remove_signal_delete = async_dispatcher_connect(
self.hass,
- SIGNAL_DELETE_ENTITY.format(self._external_id),
+ f"geo_json_events_delete_{self._external_id}",
self._delete_callback,
)
self._remove_signal_update = async_dispatcher_connect(
self.hass,
- SIGNAL_UPDATE_ENTITY.format(self._external_id),
+ f"geo_json_events_update_{self._external_id}",
self._update_callback,
)
diff --git a/homeassistant/components/geo_rss_events/sensor.py b/homeassistant/components/geo_rss_events/sensor.py
index b8891cdef0d..22f02a4218c 100644
--- a/homeassistant/components/geo_rss_events/sensor.py
+++ b/homeassistant/components/geo_rss_events/sensor.py
@@ -4,9 +4,6 @@ Generic GeoRSS events service.
Retrieves current events (typically incidents or alerts) in GeoRSS format, and
shows information on events filtered by distance to the HA instance's location
and grouped by category.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/sensor.geo_rss_events/
"""
from datetime import timedelta
import logging
@@ -121,9 +118,7 @@ class GeoRssServiceSensor(Entity):
@property
def name(self):
"""Return the name of the sensor."""
- return "{} {}".format(
- self._service_name, "Any" if self._category is None else self._category
- )
+ return f"{self._service_name} {'Any' if self._category is None else self._category}"
@property
def state(self):
diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py
index 9afc9a8bfac..cb663676512 100644
--- a/homeassistant/components/geofency/__init__.py
+++ b/homeassistant/components/geofency/__init__.py
@@ -114,7 +114,7 @@ def _is_mobile_beacon(data, mobile_beacons):
def _device_name(data):
"""Return name of device tracker."""
if ATTR_BEACON_ID in data:
- return "{}_{}".format(BEACON_DEV_PREFIX, data["name"])
+ return f"{BEACON_DEV_PREFIX}_{data['name']}"
return data["device"]
diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py
index 49bd70192ef..e730f108f8f 100644
--- a/homeassistant/components/geofency/device_tracker.py
+++ b/homeassistant/components/geofency/device_tracker.py
@@ -84,11 +84,6 @@ class GeofencyEntity(TrackerEntity, RestoreEntity):
"""Return the name of the device."""
return self._name
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
@property
def unique_id(self):
"""Return the unique ID."""
diff --git a/homeassistant/components/geonetnz_quakes/__init__.py b/homeassistant/components/geonetnz_quakes/__init__.py
index 141d0506847..9395c9dbe66 100644
--- a/homeassistant/components/geonetnz_quakes/__init__.py
+++ b/homeassistant/components/geonetnz_quakes/__init__.py
@@ -12,7 +12,6 @@ from homeassistant.const import (
CONF_LONGITUDE,
CONF_RADIUS,
CONF_SCAN_INTERVAL,
- CONF_UNIT_SYSTEM,
CONF_UNIT_SYSTEM_IMPERIAL,
LENGTH_MILES,
)
@@ -22,7 +21,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util.unit_system import METRIC_SYSTEM
-from .config_flow import configured_instances
from .const import (
CONF_MINIMUM_MAGNITUDE,
CONF_MMI,
@@ -34,10 +32,6 @@ from .const import (
DOMAIN,
FEED,
PLATFORMS,
- SIGNAL_DELETE_ENTITY,
- SIGNAL_NEW_GEOLOCATION,
- SIGNAL_STATUS,
- SIGNAL_UPDATE_ENTITY,
)
_LOGGER = logging.getLogger(__name__)
@@ -46,8 +40,8 @@ CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
- vol.Optional(CONF_LATITUDE): cv.latitude,
- vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude,
vol.Optional(CONF_MMI, default=DEFAULT_MMI): vol.All(
vol.Coerce(int), vol.Range(min=-1, max=8)
),
@@ -71,16 +65,11 @@ async def async_setup(hass, config):
return True
conf = config[DOMAIN]
-
latitude = conf.get(CONF_LATITUDE, hass.config.latitude)
longitude = conf.get(CONF_LONGITUDE, hass.config.longitude)
mmi = conf[CONF_MMI]
scan_interval = conf[CONF_SCAN_INTERVAL]
- identifier = f"{latitude}, {longitude}"
- if identifier in configured_instances(hass):
- return True
-
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
@@ -101,18 +90,15 @@ async def async_setup(hass, config):
async def async_setup_entry(hass, config_entry):
"""Set up the GeoNet NZ Quakes component as config entry."""
- if DOMAIN not in hass.data:
- hass.data[DOMAIN] = {}
- if FEED not in hass.data[DOMAIN]:
- hass.data[DOMAIN][FEED] = {}
+ hass.data.setdefault(DOMAIN, {})
+ feeds = hass.data[DOMAIN].setdefault(FEED, {})
radius = config_entry.data[CONF_RADIUS]
- unit_system = config_entry.data[CONF_UNIT_SYSTEM]
- if unit_system == CONF_UNIT_SYSTEM_IMPERIAL:
+ if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL:
radius = METRIC_SYSTEM.length(radius, LENGTH_MILES)
# Create feed entity manager for all platforms.
- manager = GeonetnzQuakesFeedEntityManager(hass, config_entry, radius, unit_system)
- hass.data[DOMAIN][FEED][config_entry.entry_id] = manager
+ manager = GeonetnzQuakesFeedEntityManager(hass, config_entry, radius)
+ feeds[config_entry.entry_id] = manager
_LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id)
await manager.async_init()
return True
@@ -134,7 +120,7 @@ async def async_unload_entry(hass, config_entry):
class GeonetnzQuakesFeedEntityManager:
"""Feed Entity Manager for GeoNet NZ Quakes feed."""
- def __init__(self, hass, config_entry, radius_in_km, unit_system):
+ def __init__(self, hass, config_entry, radius_in_km):
"""Initialize the Feed Entity Manager."""
self._hass = hass
self._config_entry = config_entry
@@ -157,7 +143,6 @@ class GeonetnzQuakesFeedEntityManager:
)
self._config_entry_id = config_entry.entry_id
self._scan_interval = timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL])
- self._unit_system = unit_system
self._track_time_remove_callback = None
self._status_info = None
self.listeners = []
@@ -200,7 +185,7 @@ class GeonetnzQuakesFeedEntityManager:
@callback
def async_event_new_entity(self):
"""Return manager specific event to signal new entity."""
- return SIGNAL_NEW_GEOLOCATION.format(self._config_entry_id)
+ return f"geonetnz_quakes_new_geolocation_{self._config_entry_id}"
def get_entry(self, external_id):
"""Get feed entry by external id."""
@@ -216,20 +201,22 @@ class GeonetnzQuakesFeedEntityManager:
self._hass,
self.async_event_new_entity(),
self,
+ self._config_entry.unique_id,
external_id,
- self._unit_system,
)
async def _update_entity(self, external_id):
"""Update entity."""
- async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id))
+ async_dispatcher_send(self._hass, f"geonetnz_quakes_update_{external_id}")
async def _remove_entity(self, external_id):
"""Remove entity."""
- async_dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id))
+ async_dispatcher_send(self._hass, f"geonetnz_quakes_delete_{external_id}")
async def _status_update(self, status_info):
"""Propagate status update."""
_LOGGER.debug("Status update received: %s", status_info)
self._status_info = status_info
- async_dispatcher_send(self._hass, SIGNAL_STATUS.format(self._config_entry_id))
+ async_dispatcher_send(
+ self._hass, f"geonetnz_quakes_status_{self._config_entry_id}"
+ )
diff --git a/homeassistant/components/geonetnz_quakes/config_flow.py b/homeassistant/components/geonetnz_quakes/config_flow.py
index cc40f31f1fb..f3f5829f465 100644
--- a/homeassistant/components/geonetnz_quakes/config_flow.py
+++ b/homeassistant/components/geonetnz_quakes/config_flow.py
@@ -9,14 +9,10 @@ from homeassistant.const import (
CONF_LONGITUDE,
CONF_RADIUS,
CONF_SCAN_INTERVAL,
- CONF_UNIT_SYSTEM,
- CONF_UNIT_SYSTEM_IMPERIAL,
- CONF_UNIT_SYSTEM_METRIC,
)
-from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
-from .const import (
+from .const import ( # pylint: disable=unused-import
CONF_MINIMUM_MAGNITUDE,
CONF_MMI,
DEFAULT_MINIMUM_MAGNITUDE,
@@ -26,37 +22,27 @@ from .const import (
DOMAIN,
)
+DATA_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_MMI, default=DEFAULT_MMI): vol.All(
+ vol.Coerce(int), vol.Range(min=-1, max=8)
+ ),
+ vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): cv.positive_int,
+ }
+)
+
_LOGGER = logging.getLogger(__name__)
-@callback
-def configured_instances(hass):
- """Return a set of configured GeoNet NZ Quakes instances."""
- return set(
- f"{entry.data[CONF_LATITUDE]}, {entry.data[CONF_LONGITUDE]}"
- for entry in hass.config_entries.async_entries(DOMAIN)
- )
-
-
-@config_entries.HANDLERS.register(DOMAIN)
-class GeonetnzQuakesFlowHandler(config_entries.ConfigFlow):
+class GeonetnzQuakesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a GeoNet NZ Quakes config flow."""
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
async def _show_form(self, errors=None):
"""Show the form to the user."""
- data_schema = vol.Schema(
- {
- vol.Optional(CONF_MMI, default=DEFAULT_MMI): vol.All(
- vol.Coerce(int), vol.Range(min=-1, max=8)
- ),
- vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): cv.positive_int,
- }
- )
-
return self.async_show_form(
- step_id="user", data_schema=data_schema, errors=errors or {}
+ step_id="user", data_schema=DATA_SCHEMA, errors=errors or {}
)
async def async_step_import(self, import_config):
@@ -75,13 +61,9 @@ class GeonetnzQuakesFlowHandler(config_entries.ConfigFlow):
user_input[CONF_LONGITUDE] = longitude
identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}"
- if identifier in configured_instances(self.hass):
- return await self._show_form({"base": "identifier_exists"})
- if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL:
- user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_IMPERIAL
- else:
- user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_METRIC
+ await self.async_set_unique_id(identifier)
+ self._abort_if_unique_id_configured()
scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds
diff --git a/homeassistant/components/geonetnz_quakes/const.py b/homeassistant/components/geonetnz_quakes/const.py
index d564d407f7c..43818b55f6f 100644
--- a/homeassistant/components/geonetnz_quakes/const.py
+++ b/homeassistant/components/geonetnz_quakes/const.py
@@ -15,9 +15,3 @@ DEFAULT_MINIMUM_MAGNITUDE = 0.0
DEFAULT_MMI = 3
DEFAULT_RADIUS = 50.0
DEFAULT_SCAN_INTERVAL = timedelta(minutes=5)
-
-SIGNAL_DELETE_ENTITY = "geonetnz_quakes_delete_{}"
-SIGNAL_UPDATE_ENTITY = "geonetnz_quakes_update_{}"
-SIGNAL_STATUS = "geonetnz_quakes_status_{}"
-
-SIGNAL_NEW_GEOLOCATION = "geonetnz_quakes_new_geolocation_{}"
diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py
index ae8b8fef48d..7d29f5ed3ec 100644
--- a/homeassistant/components/geonetnz_quakes/geo_location.py
+++ b/homeassistant/components/geonetnz_quakes/geo_location.py
@@ -12,9 +12,10 @@ 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 DOMAIN, FEED, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY
+from .const import DOMAIN, FEED
_LOGGER = logging.getLogger(__name__)
@@ -26,6 +27,9 @@ ATTR_MMI = "mmi"
ATTR_PUBLICATION_DATE = "publication_date"
ATTR_QUALITY = "quality"
+# An update of this entity is not making a web request, but uses internal data only.
+PARALLEL_UPDATES = 0
+
SOURCE = "geonetnz_quakes"
@@ -34,9 +38,9 @@ async def async_setup_entry(hass, entry, async_add_entities):
manager = hass.data[DOMAIN][FEED][entry.entry_id]
@callback
- def async_add_geolocation(feed_manager, external_id, unit_system):
+ def async_add_geolocation(feed_manager, integration_id, external_id):
"""Add gelocation entity from feed."""
- new_entity = GeonetnzQuakesEvent(feed_manager, external_id, unit_system)
+ new_entity = GeonetnzQuakesEvent(feed_manager, integration_id, external_id)
_LOGGER.debug("Adding geolocation %s", new_entity)
async_add_entities([new_entity], True)
@@ -45,6 +49,8 @@ async def async_setup_entry(hass, entry, async_add_entities):
hass, manager.async_event_new_entity(), async_add_geolocation
)
)
+ # Do not wait for update here so that the setup can be completed and because an
+ # update will fetch data from the feed via HTTP and then process that data.
hass.async_create_task(manager.async_update())
_LOGGER.debug("Geolocation setup done")
@@ -52,11 +58,11 @@ async def async_setup_entry(hass, entry, async_add_entities):
class GeonetnzQuakesEvent(GeolocationEvent):
"""This represents an external event with GeoNet NZ Quakes feed data."""
- def __init__(self, feed_manager, external_id, unit_system):
+ def __init__(self, feed_manager, integration_id, external_id):
"""Initialize entity with data from feed entry."""
self._feed_manager = feed_manager
+ self._integration_id = integration_id
self._external_id = external_id
- self._unit_system = unit_system
self._title = None
self._distance = None
self._latitude = None
@@ -75,12 +81,12 @@ class GeonetnzQuakesEvent(GeolocationEvent):
"""Call when entity is added to hass."""
self._remove_signal_delete = async_dispatcher_connect(
self.hass,
- SIGNAL_DELETE_ENTITY.format(self._external_id),
+ f"geonetnz_quakes_delete_{self._external_id}",
self._delete_callback,
)
self._remove_signal_update = async_dispatcher_connect(
self.hass,
- SIGNAL_UPDATE_ENTITY.format(self._external_id),
+ f"geonetnz_quakes_update_{self._external_id}",
self._update_callback,
)
@@ -88,6 +94,9 @@ class GeonetnzQuakesEvent(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)
+ entity_registry.async_remove(self.entity_id)
@callback
def _delete_callback(self):
@@ -115,7 +124,7 @@ class GeonetnzQuakesEvent(GeolocationEvent):
"""Update the internal state from the provided feed entry."""
self._title = feed_entry.title
# Convert distance if not metric system.
- if self._unit_system == CONF_UNIT_SYSTEM_IMPERIAL:
+ if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL:
self._distance = IMPERIAL_SYSTEM.length(
feed_entry.distance_to_home, LENGTH_KILOMETERS
)
@@ -131,6 +140,11 @@ class GeonetnzQuakesEvent(GeolocationEvent):
self._quality = feed_entry.quality
self._time = feed_entry.time
+ @property
+ def unique_id(self) -> Optional[str]:
+ """Return a unique ID containing latitude/longitude and external id."""
+ return f"{self._integration_id}_{self._external_id}"
+
@property
def icon(self):
"""Return the icon to use in the frontend, if any."""
@@ -164,7 +178,7 @@ class GeonetnzQuakesEvent(GeolocationEvent):
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
- if self._unit_system == CONF_UNIT_SYSTEM_IMPERIAL:
+ if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL:
return LENGTH_MILES
return LENGTH_KILOMETERS
diff --git a/homeassistant/components/geonetnz_quakes/manifest.json b/homeassistant/components/geonetnz_quakes/manifest.json
index 50813b062f0..613af313393 100644
--- a/homeassistant/components/geonetnz_quakes/manifest.json
+++ b/homeassistant/components/geonetnz_quakes/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/geonetnz_quakes",
"requirements": ["aio_geojson_geonetnz_quakes==0.12"],
"dependencies": [],
- "codeowners": ["@exxamalte"]
-}
+ "codeowners": ["@exxamalte"],
+ "quality_scale": "platinum"
+}
\ No newline at end of file
diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py
index e0be94d1b26..1cb2d0dc091 100644
--- a/homeassistant/components/geonetnz_quakes/sensor.py
+++ b/homeassistant/components/geonetnz_quakes/sensor.py
@@ -7,7 +7,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.util import dt
-from .const import DOMAIN, FEED, SIGNAL_STATUS
+from .const import DOMAIN, FEED
_LOGGER = logging.getLogger(__name__)
@@ -22,11 +22,14 @@ ATTR_REMOVED = "removed"
DEFAULT_ICON = "mdi:pulse"
DEFAULT_UNIT_OF_MEASUREMENT = "quakes"
+# An update of this entity is not making a web request, but uses internal data only.
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(hass, entry, async_add_entities):
"""Set up the GeoNet NZ Quakes Feed platform."""
manager = hass.data[DOMAIN][FEED][entry.entry_id]
- sensor = GeonetnzQuakesSensor(entry.entry_id, entry.title, manager)
+ sensor = GeonetnzQuakesSensor(entry.entry_id, entry.unique_id, entry.title, manager)
async_add_entities([sensor])
_LOGGER.debug("Sensor setup done")
@@ -34,9 +37,10 @@ async def async_setup_entry(hass, entry, async_add_entities):
class GeonetnzQuakesSensor(Entity):
"""This is a status sensor for the GeoNet NZ Quakes integration."""
- def __init__(self, config_entry_id, config_title, manager):
+ def __init__(self, config_entry_id, config_unique_id, config_title, manager):
"""Initialize entity."""
self._config_entry_id = config_entry_id
+ self._config_unique_id = config_unique_id
self._config_title = config_title
self._manager = manager
self._status = None
@@ -53,7 +57,7 @@ class GeonetnzQuakesSensor(Entity):
"""Call when entity is added to hass."""
self._remove_signal_status = async_dispatcher_connect(
self.hass,
- SIGNAL_STATUS.format(self._config_entry_id),
+ f"geonetnz_quakes_status_{self._config_entry_id}",
self._update_status_callback,
)
_LOGGER.debug("Waiting for updates %s", self._config_entry_id)
@@ -90,11 +94,10 @@ class GeonetnzQuakesSensor(Entity):
self._last_update = (
dt.as_utc(status_info.last_update) if status_info.last_update else None
)
- self._last_update_successful = (
- dt.as_utc(status_info.last_update_successful)
- if status_info.last_update_successful
- else None
- )
+ if status_info.last_update_successful:
+ self._last_update_successful = dt.as_utc(status_info.last_update_successful)
+ else:
+ self._last_update_successful = None
self._last_timestamp = status_info.last_timestamp
self._total = status_info.total
self._created = status_info.created
@@ -106,6 +109,11 @@ class GeonetnzQuakesSensor(Entity):
"""Return the state of the sensor."""
return self._total
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID containing latitude/longitude."""
+ return self._config_unique_id
+
@property
def name(self) -> Optional[str]:
"""Return the name of the entity."""
diff --git a/homeassistant/components/geonetnz_quakes/strings.json b/homeassistant/components/geonetnz_quakes/strings.json
index 6ec915eb68d..9c5ea291897 100644
--- a/homeassistant/components/geonetnz_quakes/strings.json
+++ b/homeassistant/components/geonetnz_quakes/strings.json
@@ -10,8 +10,8 @@
}
}
},
- "error": {
- "identifier_exists": "Location already registered"
+ "abort": {
+ "already_configured": "Location is already configured."
}
}
}
diff --git a/homeassistant/components/geonetnz_volcano/__init__.py b/homeassistant/components/geonetnz_volcano/__init__.py
index e24de7fdc5d..e2c6cb77083 100644
--- a/homeassistant/components/geonetnz_volcano/__init__.py
+++ b/homeassistant/components/geonetnz_volcano/__init__.py
@@ -24,14 +24,7 @@ from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util.unit_system import METRIC_SYSTEM
from .config_flow import configured_instances
-from .const import (
- DEFAULT_RADIUS,
- DEFAULT_SCAN_INTERVAL,
- DOMAIN,
- FEED,
- SIGNAL_NEW_SENSOR,
- SIGNAL_UPDATE_ENTITY,
-)
+from .const import DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN, FEED
_LOGGER = logging.getLogger(__name__)
@@ -173,7 +166,7 @@ class GeonetnzVolcanoFeedEntityManager:
@callback
def async_event_new_entity(self):
"""Return manager specific event to signal new entity."""
- return SIGNAL_NEW_SENSOR.format(self._config_entry_id)
+ return f"geonetnz_volcano_new_sensor_{self._config_entry_id}"
def get_entry(self, external_id):
"""Get feed entry by external id."""
@@ -199,7 +192,7 @@ class GeonetnzVolcanoFeedEntityManager:
async def _update_entity(self, external_id):
"""Update entity."""
- async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id))
+ async_dispatcher_send(self._hass, f"geonetnz_volcano_update_{external_id}")
async def _remove_entity(self, external_id):
"""Ignore removing entity."""
diff --git a/homeassistant/components/geonetnz_volcano/const.py b/homeassistant/components/geonetnz_volcano/const.py
index 7bc15d3a6a1..d48e9775f19 100644
--- a/homeassistant/components/geonetnz_volcano/const.py
+++ b/homeassistant/components/geonetnz_volcano/const.py
@@ -14,6 +14,3 @@ ATTR_HAZARDS = "hazards"
DEFAULT_ICON = "mdi:image-filter-hdr"
DEFAULT_RADIUS = 50.0
DEFAULT_SCAN_INTERVAL = timedelta(minutes=5)
-
-SIGNAL_NEW_SENSOR = "geonetnz_volcano_new_sensor_{}"
-SIGNAL_UPDATE_ENTITY = "geonetnz_volcano_update_{}"
diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py
index f87ea88fc1c..3d5d0681f02 100644
--- a/homeassistant/components/geonetnz_volcano/sensor.py
+++ b/homeassistant/components/geonetnz_volcano/sensor.py
@@ -23,7 +23,6 @@ from .const import (
DEFAULT_ICON,
DOMAIN,
FEED,
- SIGNAL_UPDATE_ENTITY,
)
_LOGGER = logging.getLogger(__name__)
@@ -79,7 +78,7 @@ class GeonetnzVolcanoSensor(Entity):
"""Call when entity is added to hass."""
self._remove_signal_update = async_dispatcher_connect(
self.hass,
- SIGNAL_UPDATE_ENTITY.format(self._external_id),
+ f"geonetnz_volcano_update_{self._external_id}",
self._update_callback,
)
diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py
index c77cf7930b8..26199763036 100644
--- a/homeassistant/components/github/sensor.py
+++ b/homeassistant/components/github/sensor.py
@@ -22,6 +22,7 @@ CONF_REPOS = "repositories"
ATTR_LATEST_COMMIT_MESSAGE = "latest_commit_message"
ATTR_LATEST_COMMIT_SHA = "latest_commit_sha"
+ATTR_LATEST_RELEASE_TAG = "latest_release_tag"
ATTR_LATEST_RELEASE_URL = "latest_release_url"
ATTR_LATEST_OPEN_ISSUE_URL = "latest_open_issue_url"
ATTR_OPEN_ISSUES = "open_issues"
@@ -61,8 +62,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
"Error setting up GitHub platform. %s",
"Check previous errors for details",
)
- return
- sensors.append(GitHubSensor(data))
+ else:
+ sensors.append(GitHubSensor(data))
add_entities(sensors, True)
@@ -78,6 +79,7 @@ class GitHubSensor(Entity):
self._repository_path = None
self._latest_commit_message = None
self._latest_commit_sha = None
+ self._latest_release_tag = None
self._latest_release_url = None
self._open_issue_count = None
self._latest_open_issue_url = None
@@ -109,7 +111,7 @@ class GitHubSensor(Entity):
@property
def device_state_attributes(self):
"""Return the state attributes."""
- return {
+ attrs = {
ATTR_PATH: self._repository_path,
ATTR_NAME: self._name,
ATTR_LATEST_COMMIT_MESSAGE: self._latest_commit_message,
@@ -121,6 +123,9 @@ class GitHubSensor(Entity):
ATTR_OPEN_PULL_REQUESTS: self._pull_request_count,
ATTR_STARGAZERS: self._stargazers,
}
+ if self._latest_release_tag is not None:
+ attrs[ATTR_LATEST_RELEASE_TAG] = self._latest_release_tag
+ return attrs
@property
def icon(self):
@@ -132,12 +137,18 @@ class GitHubSensor(Entity):
self._github_data.update()
self._name = self._github_data.name
- self._state = self._github_data.latest_commit_sha
self._repository_path = self._github_data.repository_path
self._available = self._github_data.available
self._latest_commit_message = self._github_data.latest_commit_message
self._latest_commit_sha = self._github_data.latest_commit_sha
+ if self._github_data.latest_release_url is not None:
+ self._latest_release_tag = self._github_data.latest_release_url.split(
+ "tag/"
+ )[1]
+ else:
+ self._latest_release_tag = None
self._latest_release_url = self._github_data.latest_release_url
+ self._state = self._github_data.latest_commit_sha[0:7]
self._open_issue_count = self._github_data.open_issue_count
self._latest_open_issue_url = self._github_data.latest_open_issue_url
self._pull_request_count = self._github_data.pull_request_count
@@ -170,7 +181,6 @@ class GitHubData:
return
self.name = repository.get(CONF_NAME, repo.name)
-
self.available = False
self.latest_commit_message = None
self.latest_commit_sha = None
diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py
index 31a3f0f69e4..53dc6352049 100644
--- a/homeassistant/components/glances/const.py
+++ b/homeassistant/components/glances/const.py
@@ -1,5 +1,10 @@
"""Constants for Glances component."""
-from homeassistant.const import DATA_GIBIBYTES, DATA_MEBIBYTES, TEMP_CELSIUS
+from homeassistant.const import (
+ DATA_GIBIBYTES,
+ DATA_MEBIBYTES,
+ TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
+)
DOMAIN = "glances"
CONF_VERSION = "version"
@@ -14,13 +19,13 @@ DATA_UPDATED = "glances_data_updated"
SUPPORTED_VERSIONS = [2, 3]
SENSOR_TYPES = {
- "disk_use_percent": ["fs", "used percent", "%", "mdi:harddisk"],
+ "disk_use_percent": ["fs", "used percent", UNIT_PERCENTAGE, "mdi:harddisk"],
"disk_use": ["fs", "used", DATA_GIBIBYTES, "mdi:harddisk"],
"disk_free": ["fs", "free", DATA_GIBIBYTES, "mdi:harddisk"],
- "memory_use_percent": ["mem", "RAM used percent", "%", "mdi:memory"],
+ "memory_use_percent": ["mem", "RAM used percent", UNIT_PERCENTAGE, "mdi:memory"],
"memory_use": ["mem", "RAM used", DATA_MEBIBYTES, "mdi:memory"],
"memory_free": ["mem", "RAM free", DATA_MEBIBYTES, "mdi:memory"],
- "swap_use_percent": ["memswap", "Swap used percent", "%", "mdi:memory"],
+ "swap_use_percent": ["memswap", "Swap used percent", UNIT_PERCENTAGE, "mdi:memory"],
"swap_use": ["memswap", "Swap used", DATA_GIBIBYTES, "mdi:memory"],
"swap_free": ["memswap", "Swap free", DATA_GIBIBYTES, "mdi:memory"],
"processor_load": ["load", "CPU load", "15 min", "mdi:memory"],
@@ -28,10 +33,10 @@ SENSOR_TYPES = {
"process_total": ["processcount", "Total", "Count", "mdi:memory"],
"process_thread": ["processcount", "Thread", "Count", "mdi:memory"],
"process_sleeping": ["processcount", "Sleeping", "Count", "mdi:memory"],
- "cpu_use_percent": ["cpu", "CPU used", "%", "mdi:memory"],
+ "cpu_use_percent": ["cpu", "CPU used", UNIT_PERCENTAGE, "mdi:memory"],
"sensor_temp": ["sensors", "Temp", TEMP_CELSIUS, "mdi:thermometer"],
"docker_active": ["docker", "Containers active", "", "mdi:docker"],
- "docker_cpu_use": ["docker", "Containers CPU used", "%", "mdi:docker"],
+ "docker_cpu_use": ["docker", "Containers CPU used", UNIT_PERCENTAGE, "mdi:docker"],
"docker_memory_use": [
"docker",
"Containers RAM used",
diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py
index fcb0182ec0e..62aea62bf84 100644
--- a/homeassistant/components/gogogate2/cover.py
+++ b/homeassistant/components/gogogate2/cover.py
@@ -51,9 +51,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
except (TypeError, KeyError, NameError, ValueError) as ex:
_LOGGER.error("%s", ex)
hass.components.persistent_notification.create(
- "Error: {}
"
- "You will need to restart hass after fixing."
- "".format(ex),
+ (f"Error: {ex}
You will need to restart hass after fixing."),
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py
index 0e7ccd33b33..f3321416b1f 100644
--- a/homeassistant/components/google/__init__.py
+++ b/homeassistant/components/google/__init__.py
@@ -144,17 +144,17 @@ def do_authentication(hass, hass_config, config):
dev_flow = oauth.step1_get_device_and_user_codes()
except OAuth2DeviceCodeError as err:
hass.components.persistent_notification.create(
- "Error: {}
You will need to restart hass after fixing." "".format(err),
+ f"Error: {err}
You will need to restart hass after fixing." "",
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
return False
hass.components.persistent_notification.create(
- "In order to authorize Home-Assistant to view your calendars "
- 'you must visit: {} and enter '
- "code: {}".format(
- dev_flow.verification_url, dev_flow.verification_url, dev_flow.user_code
+ (
+ f"In order to authorize Home-Assistant to view your calendars "
+ f'you must visit: {dev_flow.verification_url} and enter '
+ f"code: {dev_flow.user_code}"
),
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
@@ -182,8 +182,10 @@ def do_authentication(hass, hass_config, config):
do_setup(hass, hass_config, config)
listener()
hass.components.persistent_notification.create(
- "We are all setup now. Check {} for calendars that have "
- "been found".format(YAML_DEVICES),
+ (
+ f"We are all setup now. Check {YAML_DEVICES} for calendars that have "
+ f"been found"
+ ),
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py
index 60e4cdae6a5..4c16e230e92 100644
--- a/homeassistant/components/google_assistant/http.py
+++ b/homeassistant/components/google_assistant/http.py
@@ -53,7 +53,7 @@ def _get_homegraph_jwt(time, iss, key):
async def _get_homegraph_token(hass, jwt_signed):
headers = {
- "Authorization": "Bearer {}".format(jwt_signed),
+ "Authorization": f"Bearer {jwt_signed}",
"Content-Type": "application/x-www-form-urlencoded",
}
data = {
@@ -185,7 +185,7 @@ class GoogleConfig(AbstractConfig):
async def _call():
headers = {
- "Authorization": "Bearer {}".format(self._access_token),
+ "Authorization": f"Bearer {self._access_token}",
"X-GFE-SSL": "yes",
}
async with session.post(url, headers=headers, json=data) as res:
diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py
index 1e8b6c020de..d6bcafd3bff 100644
--- a/homeassistant/components/google_assistant/report_state.py
+++ b/homeassistant/components/google_assistant/report_state.py
@@ -21,6 +21,9 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig
"""Enable state reporting."""
async def async_entity_state_listener(changed_entity, old_state, new_state):
+ if not hass.is_running:
+ return
+
if not new_state:
return
diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py
index b4585ebde03..9da319226fa 100644
--- a/homeassistant/components/google_assistant/trait.py
+++ b/homeassistant/components/google_assistant/trait.py
@@ -392,9 +392,7 @@ class ColorSettingTrait(_Trait):
if temp < min_temp or temp > max_temp:
raise SmartHomeError(
ERR_VALUE_OUT_OF_RANGE,
- "Temperature should be between {} and {}".format(
- min_temp, max_temp
- ),
+ f"Temperature should be between {min_temp} and {max_temp}",
)
await self.hass.services.async_call(
@@ -407,7 +405,7 @@ class ColorSettingTrait(_Trait):
elif "spectrumRGB" in params["color"]:
# Convert integer to hex format and left pad with 0's till length 6
- hex_value = "{0:06x}".format(params["color"]["spectrumRGB"])
+ hex_value = f"{params['color']['spectrumRGB']:06x}"
color = color_util.color_RGB_to_hs(
*color_util.rgb_hex_to_rgb_list(hex_value)
)
@@ -746,9 +744,7 @@ class TemperatureSettingTrait(_Trait):
if temp < min_temp or temp > max_temp:
raise SmartHomeError(
ERR_VALUE_OUT_OF_RANGE,
- "Temperature should be between {} and {}".format(
- min_temp, max_temp
- ),
+ f"Temperature should be between {min_temp} and {max_temp}",
)
await self.hass.services.async_call(
@@ -769,8 +765,10 @@ class TemperatureSettingTrait(_Trait):
if temp_high < min_temp or temp_high > max_temp:
raise SmartHomeError(
ERR_VALUE_OUT_OF_RANGE,
- "Upper bound for temperature range should be between "
- "{} and {}".format(min_temp, max_temp),
+ (
+ f"Upper bound for temperature range should be between "
+ f"{min_temp} and {max_temp}"
+ ),
)
temp_low = temp_util.convert(
@@ -782,8 +780,10 @@ class TemperatureSettingTrait(_Trait):
if temp_low < min_temp or temp_low > max_temp:
raise SmartHomeError(
ERR_VALUE_OUT_OF_RANGE,
- "Lower bound for temperature range should be between "
- "{} and {}".format(min_temp, max_temp),
+ (
+ f"Lower bound for temperature range should be between "
+ f"{min_temp} and {max_temp}"
+ ),
)
supported = self.state.attributes.get(ATTR_SUPPORTED_FEATURES)
diff --git a/homeassistant/components/google_domains/__init__.py b/homeassistant/components/google_domains/__init__.py
index d440567d9ad..ae6cb5c70d5 100644
--- a/homeassistant/components/google_domains/__init__.py
+++ b/homeassistant/components/google_domains/__init__.py
@@ -18,8 +18,6 @@ INTERVAL = timedelta(minutes=5)
DEFAULT_TIMEOUT = 10
-UPDATE_URL = "https://{}:{}@domains.google.com/nic/update"
-
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
@@ -62,7 +60,7 @@ async def async_setup(hass, config):
async def _update_google_domains(hass, session, domain, user, password, timeout):
"""Update Google Domains."""
- url = UPDATE_URL.format(user, password)
+ url = f"https://{user}:{password}@domains.google.com/nic/update"
params = {"hostname": domain}
diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py
index 9e33ff5f715..7b48c12cc93 100644
--- a/homeassistant/components/google_maps/device_tracker.py
+++ b/homeassistant/components/google_maps/device_tracker.py
@@ -55,9 +55,7 @@ class GoogleMapsScanner:
self.scan_interval = config.get(CONF_SCAN_INTERVAL) or timedelta(seconds=60)
self._prev_seen = {}
- credfile = "{}.{}".format(
- hass.config.path(CREDENTIALS_FILE), slugify(self.username)
- )
+ credfile = f"{hass.config.path(CREDENTIALS_FILE)}.{slugify(self.username)}"
try:
self.service = Service(credfile, self.username)
self._update_info()
@@ -75,7 +73,7 @@ class GoogleMapsScanner:
def _update_info(self, now=None):
for person in self.service.get_all_people():
try:
- dev_id = "google_maps_{0}".format(slugify(person.id))
+ dev_id = f"google_maps_{slugify(person.id)}"
except TypeError:
_LOGGER.warning("No location(s) shared with this account")
return
diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py
index 3ee72928fc1..dd7d9bf8585 100644
--- a/homeassistant/components/google_travel_time/sensor.py
+++ b/homeassistant/components/google_travel_time/sensor.py
@@ -14,11 +14,11 @@ from homeassistant.const import (
CONF_MODE,
CONF_NAME,
EVENT_HOMEASSISTANT_START,
+ TIME_MINUTES,
)
from homeassistant.helpers import location
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
-from homeassistant.util import Throttle
import homeassistant.util.dt as dt_util
_LOGGER = logging.getLogger(__name__)
@@ -32,7 +32,7 @@ CONF_TRAVEL_MODE = "travel_mode"
DEFAULT_NAME = "Google Travel Time"
-MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5)
+SCAN_INTERVAL = timedelta(minutes=5)
ALL_LANGUAGES = [
"ar",
@@ -162,7 +162,7 @@ def setup_platform(hass, config, add_entities_callback, discovery_info=None):
options[CONF_MODE] = travel_mode
titled_mode = options.get(CONF_MODE).title()
- formatted_name = "{} - {}".format(DEFAULT_NAME, titled_mode)
+ formatted_name = f"{DEFAULT_NAME} - {titled_mode}"
name = config.get(CONF_NAME, formatted_name)
api_key = config.get(CONF_API_KEY)
origin = config.get(CONF_ORIGIN)
@@ -188,7 +188,7 @@ class GoogleTravelTimeSensor(Entity):
self._hass = hass
self._name = name
self._options = options
- self._unit_of_measurement = "min"
+ self._unit_of_measurement = TIME_MINUTES
self._matrix = None
self.valid_api_connection = True
@@ -255,7 +255,6 @@ class GoogleTravelTimeSensor(Entity):
"""Return the unit this state is expressed in."""
return self._unit_of_measurement
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Get the latest data from Google."""
options_copy = self._options.copy()
diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py
index 9d6f3ea3d58..9dfa26fab75 100644
--- a/homeassistant/components/google_wifi/sensor.py
+++ b/homeassistant/components/google_wifi/sensor.py
@@ -11,6 +11,7 @@ from homeassistant.const import (
CONF_MONITORED_CONDITIONS,
CONF_NAME,
STATE_UNKNOWN,
+ TIME_DAYS,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -39,7 +40,7 @@ MONITORED_CONDITIONS = {
"mdi:checkbox-marked-circle-outline",
],
ATTR_NEW_VERSION: [["software", "updateNewVersion"], None, "mdi:update"],
- ATTR_UPTIME: [["system", "uptime"], "days", "mdi:timelapse"],
+ ATTR_UPTIME: [["system", "uptime"], TIME_DAYS, "mdi:timelapse"],
ATTR_LAST_RESTART: [["system", "uptime"], None, "mdi:restart"],
ATTR_LOCAL_IP: [["wan", "localIpAddress"], None, "mdi:access-point-network"],
ATTR_STATUS: [["wan", "online"], None, "mdi:google"],
diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py
index d8afc377d40..d294b07ebc7 100644
--- a/homeassistant/components/gpslogger/device_tracker.py
+++ b/homeassistant/components/gpslogger/device_tracker.py
@@ -107,11 +107,6 @@ class GPSLoggerEntity(TrackerEntity, RestoreEntity):
"""Return the name of the device."""
return self._name
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
@property
def unique_id(self):
"""Return the unique ID."""
diff --git a/homeassistant/components/greeneye_monitor/__init__.py b/homeassistant/components/greeneye_monitor/__init__.py
index dcd383a7463..697a96649ab 100644
--- a/homeassistant/components/greeneye_monitor/__init__.py
+++ b/homeassistant/components/greeneye_monitor/__init__.py
@@ -9,6 +9,9 @@ from homeassistant.const import (
CONF_PORT,
CONF_TEMPERATURE_UNIT,
EVENT_HOMEASSISTANT_STOP,
+ TIME_HOURS,
+ TIME_MINUTES,
+ TIME_SECONDS,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
@@ -40,10 +43,6 @@ SENSOR_TYPE_VOLTAGE = "voltage_sensor"
TEMPERATURE_UNIT_CELSIUS = "C"
-TIME_UNIT_SECOND = "s"
-TIME_UNIT_MINUTE = "min"
-TIME_UNIT_HOUR = "h"
-
TEMPERATURE_SENSOR_SCHEMA = vol.Schema(
{vol.Required(CONF_NUMBER): vol.Range(1, 8), vol.Required(CONF_NAME): cv.string}
)
@@ -69,8 +68,8 @@ PULSE_COUNTER_SCHEMA = vol.Schema(
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_COUNTED_QUANTITY): cv.string,
vol.Optional(CONF_COUNTED_QUANTITY_PER_PULSE, default=1.0): vol.Coerce(float),
- vol.Optional(CONF_TIME_UNIT, default=TIME_UNIT_SECOND): vol.Any(
- TIME_UNIT_SECOND, TIME_UNIT_MINUTE, TIME_UNIT_HOUR
+ vol.Optional(CONF_TIME_UNIT, default=TIME_SECONDS): vol.Any(
+ TIME_SECONDS, TIME_MINUTES, TIME_HOURS
),
}
)
diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py
index 2640c701f92..1d53525ab37 100644
--- a/homeassistant/components/greeneye_monitor/sensor.py
+++ b/homeassistant/components/greeneye_monitor/sensor.py
@@ -1,7 +1,14 @@
"""Support for the sensors in a GreenEye Monitor."""
import logging
-from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, POWER_WATT
+from homeassistant.const import (
+ CONF_NAME,
+ CONF_TEMPERATURE_UNIT,
+ POWER_WATT,
+ TIME_HOURS,
+ TIME_MINUTES,
+ TIME_SECONDS,
+)
from homeassistant.helpers.entity import Entity
from . import (
@@ -17,9 +24,6 @@ from . import (
SENSOR_TYPE_PULSE_COUNTER,
SENSOR_TYPE_TEMPERATURE,
SENSOR_TYPE_VOLTAGE,
- TIME_UNIT_HOUR,
- TIME_UNIT_MINUTE,
- TIME_UNIT_SECOND,
)
_LOGGER = logging.getLogger(__name__)
@@ -103,11 +107,7 @@ class GEMSensor(Entity):
@property
def unique_id(self):
"""Return a unique ID for this sensor."""
- return "{serial}-{sensor_type}-{number}".format(
- serial=self._monitor_serial_number,
- sensor_type=self._sensor_type,
- number=self._number,
- )
+ return f"{self._monitor_serial_number}-{self._sensor_type }-{self._number}"
@property
def name(self):
@@ -235,19 +235,17 @@ class PulseCounter(GEMSensor):
@property
def _seconds_per_time_unit(self):
"""Return the number of seconds in the given display time unit."""
- if self._time_unit == TIME_UNIT_SECOND:
+ if self._time_unit == TIME_SECONDS:
return 1
- if self._time_unit == TIME_UNIT_MINUTE:
+ if self._time_unit == TIME_MINUTES:
return 60
- if self._time_unit == TIME_UNIT_HOUR:
+ if self._time_unit == TIME_HOURS:
return 3600
@property
def unit_of_measurement(self):
"""Return the unit of measurement for this pulse counter."""
- return "{counted_quantity}/{time_unit}".format(
- counted_quantity=self._counted_quantity, time_unit=self._time_unit
- )
+ return f"{self._counted_quantity}/{self._time_unit}"
@property
def device_state_attributes(self):
diff --git a/homeassistant/components/griddy/.translations/en.json b/homeassistant/components/griddy/.translations/en.json
new file mode 100644
index 00000000000..bedd85e7508
--- /dev/null
+++ b/homeassistant/components/griddy/.translations/en.json
@@ -0,0 +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"
+ }
+ }
+}
diff --git a/homeassistant/components/griddy/__init__.py b/homeassistant/components/griddy/__init__.py
new file mode 100644
index 00000000000..fb5079b00f8
--- /dev/null
+++ b/homeassistant/components/griddy/__init__.py
@@ -0,0 +1,96 @@
+"""The Griddy Power integration."""
+import asyncio
+from datetime import timedelta
+import logging
+
+from griddypower.async_api import LOAD_ZONES, AsyncGriddy
+import voluptuous as vol
+
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+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 CONF_LOADZONE, DOMAIN, UPDATE_INTERVAL
+
+_LOGGER = logging.getLogger(__name__)
+
+CONFIG_SCHEMA = vol.Schema(
+ {DOMAIN: vol.Schema({vol.Required(CONF_LOADZONE): vol.In(LOAD_ZONES)})},
+ extra=vol.ALLOW_EXTRA,
+)
+
+PLATFORMS = ["sensor"]
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+ """Set up the Griddy Power 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_LOADZONE: conf.get(CONF_LOADZONE)},
+ )
+ )
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Set up Griddy Power from a config entry."""
+
+ entry_data = entry.data
+
+ async_griddy = AsyncGriddy(
+ aiohttp_client.async_get_clientsession(hass),
+ settlement_point=entry_data[CONF_LOADZONE],
+ )
+
+ async def async_update_data():
+ """Fetch data from API endpoint."""
+ return await async_griddy.async_getnow()
+
+ coordinator = DataUpdateCoordinator(
+ hass,
+ _LOGGER,
+ name="Griddy getnow",
+ update_method=async_update_data,
+ update_interval=timedelta(seconds=UPDATE_INTERVAL),
+ )
+
+ await coordinator.async_refresh()
+
+ if not coordinator.last_update_success:
+ raise ConfigEntryNotReady
+
+ hass.data[DOMAIN][entry.entry_id] = coordinator
+
+ for component in PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, component)
+ )
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Unload a config entry."""
+ unload_ok = all(
+ await asyncio.gather(
+ *[
+ hass.config_entries.async_forward_entry_unload(entry, component)
+ for component in PLATFORMS
+ ]
+ )
+ )
+ if unload_ok:
+ hass.data[DOMAIN].pop(entry.entry_id)
+
+ return unload_ok
diff --git a/homeassistant/components/griddy/config_flow.py b/homeassistant/components/griddy/config_flow.py
new file mode 100644
index 00000000000..56284384ee0
--- /dev/null
+++ b/homeassistant/components/griddy/config_flow.py
@@ -0,0 +1,75 @@
+"""Config flow for Griddy Power integration."""
+import asyncio
+import logging
+
+from aiohttp import ClientError
+from griddypower.async_api import LOAD_ZONES, AsyncGriddy
+import voluptuous as vol
+
+from homeassistant import config_entries, core, exceptions
+from homeassistant.helpers import aiohttp_client
+
+from .const import CONF_LOADZONE
+from .const import DOMAIN # pylint:disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_SCHEMA = vol.Schema({vol.Required(CONF_LOADZONE): vol.In(LOAD_ZONES)})
+
+
+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.
+ """
+ client_session = aiohttp_client.async_get_clientsession(hass)
+
+ try:
+ await AsyncGriddy(
+ client_session, settlement_point=data[CONF_LOADZONE]
+ ).async_getnow()
+ except (asyncio.TimeoutError, ClientError):
+ raise CannotConnect
+
+ # Return info that you want to store in the config entry.
+ return {"title": f"Load Zone {data[CONF_LOADZONE]}"}
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Griddy Power."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ async def async_step_user(self, user_input=None):
+ """Handle the initial step."""
+ errors = {}
+ info = None
+ 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_LOADZONE])
+ 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_LOADZONE])
+ 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/griddy/const.py b/homeassistant/components/griddy/const.py
new file mode 100644
index 00000000000..034567a806e
--- /dev/null
+++ b/homeassistant/components/griddy/const.py
@@ -0,0 +1,7 @@
+"""Constants for the Griddy Power integration."""
+
+DOMAIN = "griddy"
+
+UPDATE_INTERVAL = 90
+
+CONF_LOADZONE = "loadzone"
diff --git a/homeassistant/components/griddy/manifest.json b/homeassistant/components/griddy/manifest.json
new file mode 100644
index 00000000000..d17ed846fd9
--- /dev/null
+++ b/homeassistant/components/griddy/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "griddy",
+ "name": "Griddy Power",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/griddy",
+ "requirements": ["griddypower==0.1.0"],
+ "ssdp": [],
+ "zeroconf": [],
+ "homekit": {},
+ "dependencies": [],
+ "codeowners": [
+ "@bdraco"
+ ]
+}
diff --git a/homeassistant/components/griddy/sensor.py b/homeassistant/components/griddy/sensor.py
new file mode 100644
index 00000000000..31488650dc2
--- /dev/null
+++ b/homeassistant/components/griddy/sensor.py
@@ -0,0 +1,76 @@
+"""Support for August sensors."""
+import logging
+
+from homeassistant.helpers.entity import Entity
+
+from .const import CONF_LOADZONE, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the August sensors."""
+ coordinator = hass.data[DOMAIN][config_entry.entry_id]
+
+ settlement_point = config_entry.data[CONF_LOADZONE]
+
+ async_add_entities([GriddyPriceSensor(settlement_point, coordinator)], True)
+
+
+class GriddyPriceSensor(Entity):
+ """Representation of an August sensor."""
+
+ def __init__(self, settlement_point, coordinator):
+ """Initialize the sensor."""
+ self._coordinator = coordinator
+ self._settlement_point = settlement_point
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return "¢/kWh"
+
+ @property
+ def name(self):
+ """Device Name."""
+ return f"{self._settlement_point} Price Now"
+
+ @property
+ def icon(self):
+ """Device Ice."""
+ return "mdi:currency-usd"
+
+ @property
+ def unique_id(self):
+ """Device Uniqueid."""
+ return f"{self._settlement_point}_price_now"
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._coordinator.last_update_success
+
+ @property
+ def state(self):
+ """Get the current price."""
+ return round(float(self._coordinator.data.now.price_cents_kwh), 4)
+
+ @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/griddy/strings.json b/homeassistant/components/griddy/strings.json
new file mode 100644
index 00000000000..bedd85e7508
--- /dev/null
+++ b/homeassistant/components/griddy/strings.json
@@ -0,0 +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"
+ }
+ }
+}
diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py
index 7257959700f..f8a10017cab 100644
--- a/homeassistant/components/group/__init__.py
+++ b/homeassistant/components/group/__init__.py
@@ -30,7 +30,6 @@ from homeassistant.const import (
)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.config_validation import make_entity_service_schema
from homeassistant.helpers.entity import Entity, async_generate_entity_id
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_state_change
@@ -44,26 +43,18 @@ DOMAIN = "group"
ENTITY_ID_FORMAT = DOMAIN + ".{}"
CONF_ENTITIES = "entities"
-CONF_VIEW = "view"
-CONF_CONTROL = "control"
CONF_ALL = "all"
ATTR_ADD_ENTITIES = "add_entities"
ATTR_AUTO = "auto"
-ATTR_CONTROL = "control"
ATTR_ENTITIES = "entities"
ATTR_OBJECT_ID = "object_id"
ATTR_ORDER = "order"
-ATTR_VIEW = "view"
-ATTR_VISIBLE = "visible"
ATTR_ALL = "all"
-SERVICE_SET_VISIBILITY = "set_visibility"
SERVICE_SET = "set"
SERVICE_REMOVE = "remove"
-CONTROL_TYPES = vol.In(["hidden", None])
-
_LOGGER = logging.getLogger(__name__)
@@ -76,18 +67,14 @@ def _conf_preprocess(value):
GROUP_SCHEMA = vol.All(
- cv.deprecated(CONF_CONTROL, invalidation_version="0.107.0"),
- cv.deprecated(CONF_VIEW, invalidation_version="0.107.0"),
vol.Schema(
{
vol.Optional(CONF_ENTITIES): vol.Any(cv.entity_ids, None),
- CONF_VIEW: cv.boolean,
CONF_NAME: cv.string,
CONF_ICON: cv.icon,
- CONF_CONTROL: CONTROL_TYPES,
CONF_ALL: cv.boolean,
}
- ),
+ )
)
CONFIG_SCHEMA = vol.Schema(
@@ -244,7 +231,7 @@ async def async_setup(hass, config):
async def groups_service_handler(service):
"""Handle dynamic group service functions."""
object_id = service.data[ATTR_OBJECT_ID]
- entity_id = ENTITY_ID_FORMAT.format(object_id)
+ entity_id = f"{DOMAIN}.{object_id}"
group = component.get_entity(entity_id)
# new group
@@ -257,7 +244,7 @@ async def async_setup(hass, config):
extra_arg = {
attr: service.data[attr]
- for attr in (ATTR_VISIBLE, ATTR_ICON, ATTR_VIEW, ATTR_CONTROL)
+ for attr in (ATTR_ICON,)
if service.data.get(attr) is not None
}
@@ -293,22 +280,10 @@ async def async_setup(hass, config):
group.name = service.data[ATTR_NAME]
need_update = True
- if ATTR_VISIBLE in service.data:
- group.visible = service.data[ATTR_VISIBLE]
- need_update = True
-
if ATTR_ICON in service.data:
group.icon = service.data[ATTR_ICON]
need_update = True
- if ATTR_CONTROL in service.data:
- group.control = service.data[ATTR_CONTROL]
- need_update = True
-
- if ATTR_VIEW in service.data:
- group.view = service.data[ATTR_VIEW]
- need_update = True
-
if ATTR_ALL in service.data:
group.mode = all if service.data[ATTR_ALL] else any
need_update = True
@@ -327,22 +302,16 @@ async def async_setup(hass, config):
SERVICE_SET,
locked_service_handler,
schema=vol.All(
- cv.deprecated(ATTR_CONTROL, invalidation_version="0.107.0"),
- cv.deprecated(ATTR_VIEW, invalidation_version="0.107.0"),
- cv.deprecated(ATTR_VISIBLE, invalidation_version="0.107.0"),
vol.Schema(
{
vol.Required(ATTR_OBJECT_ID): cv.slug,
vol.Optional(ATTR_NAME): cv.string,
- vol.Optional(ATTR_VIEW): cv.boolean,
vol.Optional(ATTR_ICON): cv.string,
- vol.Optional(ATTR_CONTROL): CONTROL_TYPES,
- vol.Optional(ATTR_VISIBLE): cv.boolean,
vol.Optional(ATTR_ALL): cv.boolean,
vol.Exclusive(ATTR_ENTITIES, "entities"): cv.entity_ids,
vol.Exclusive(ATTR_ADD_ENTITIES, "entities"): cv.entity_ids,
}
- ),
+ )
),
)
@@ -353,32 +322,6 @@ async def async_setup(hass, config):
schema=vol.Schema({vol.Required(ATTR_OBJECT_ID): cv.slug}),
)
- async def visibility_service_handler(service):
- """Change visibility of a group."""
- visible = service.data.get(ATTR_VISIBLE)
-
- _LOGGER.warning(
- "The group.set_visibility service has been deprecated and will"
- "be removed in Home Assistant 0.107.0."
- )
-
- tasks = []
- for group in await component.async_extract_from_service(
- service, expand_group=False
- ):
- group.visible = visible
- tasks.append(group.async_update_ha_state())
-
- if tasks:
- await asyncio.wait(tasks)
-
- hass.services.async_register(
- DOMAIN,
- SERVICE_SET_VISIBILITY,
- visibility_service_handler,
- schema=make_entity_service_schema({vol.Required(ATTR_VISIBLE): cv.boolean}),
- )
-
return True
@@ -388,21 +331,12 @@ async def _async_process_config(hass, config, component):
name = conf.get(CONF_NAME, object_id)
entity_ids = conf.get(CONF_ENTITIES) or []
icon = conf.get(CONF_ICON)
- view = conf.get(CONF_VIEW)
- control = conf.get(CONF_CONTROL)
mode = conf.get(CONF_ALL)
# Don't create tasks and await them all. The order is important as
# groups get a number based on creation order.
await Group.async_create_group(
- hass,
- name,
- entity_ids,
- icon=icon,
- view=view,
- control=control,
- object_id=object_id,
- mode=mode,
+ hass, name, entity_ids, icon=icon, object_id=object_id, mode=mode
)
@@ -414,10 +348,7 @@ class Group(Entity):
hass,
name,
order=None,
- visible=True,
icon=None,
- view=False,
- control=None,
user_defined=True,
entity_ids=None,
mode=None,
@@ -430,15 +361,12 @@ class Group(Entity):
self._name = name
self._state = STATE_UNKNOWN
self._icon = icon
- self.view = view
if entity_ids:
self.tracking = tuple(ent_id.lower() for ent_id in entity_ids)
else:
self.tracking = tuple()
self.group_on = None
self.group_off = None
- self.visible = visible
- self.control = control
self.user_defined = user_defined
self.mode = any
if mode:
@@ -453,26 +381,14 @@ class Group(Entity):
name,
entity_ids=None,
user_defined=True,
- visible=True,
icon=None,
- view=False,
- control=None,
object_id=None,
mode=None,
):
"""Initialize a group."""
return asyncio.run_coroutine_threadsafe(
Group.async_create_group(
- hass,
- name,
- entity_ids,
- user_defined,
- visible,
- icon,
- view,
- control,
- object_id,
- mode,
+ hass, name, entity_ids, user_defined, icon, object_id, mode
),
hass.loop,
).result()
@@ -483,10 +399,7 @@ class Group(Entity):
name,
entity_ids=None,
user_defined=True,
- visible=True,
icon=None,
- view=False,
- control=None,
object_id=None,
mode=None,
):
@@ -498,10 +411,7 @@ class Group(Entity):
hass,
name,
order=len(hass.states.async_entity_ids(DOMAIN)),
- visible=visible,
icon=icon,
- view=view,
- control=control,
user_defined=user_defined,
entity_ids=entity_ids,
mode=mode,
@@ -551,23 +461,12 @@ class Group(Entity):
"""Set Icon for group."""
self._icon = value
- @property
- def hidden(self):
- """If group should be hidden or not."""
- if self.visible and not self.view:
- return False
- return True
-
@property
def state_attributes(self):
"""Return the state attributes for the group."""
data = {ATTR_ENTITY_ID: self.tracking, ATTR_ORDER: self._order}
if not self.user_defined:
data[ATTR_AUTO] = True
- if self.view:
- data[ATTR_VIEW] = True
- if self.control:
- data[ATTR_CONTROL] = self.control
return data
@property
diff --git a/homeassistant/components/group/services.yaml b/homeassistant/components/group/services.yaml
index 68c2f04f064..98b0cef69c3 100644
--- a/homeassistant/components/group/services.yaml
+++ b/homeassistant/components/group/services.yaml
@@ -3,37 +3,18 @@
reload:
description: Reload group configuration.
-set_visibility:
- description: Hide or show a group.
- fields:
- entity_id:
- description: Name(s) of entities to set value.
- example: 'group.travel'
- visible:
- description: True if group should be shown or False if it should be hidden.
- example: True
-
set:
description: Create/Update a user group.
fields:
object_id:
description: Group id and part of entity id.
- example: 'test_group'
+ example: "test_group"
name:
description: Name of group
- example: 'My test group'
- view:
- description: Boolean for if the group is a view.
- example: True
+ example: "My test group"
icon:
description: Name of icon for the group.
- example: 'mdi:camera'
- control:
- description: Value for control the group control.
- example: 'hidden'
- visible:
- description: If the group is visible on UI.
- example: True
+ example: "mdi:camera"
entities:
description: List of all members in the group. Not compatible with 'delta'.
example: domain.entity_id1, domain.entity_id2
@@ -49,5 +30,4 @@ remove:
fields:
object_id:
description: Group id and part of entity id.
- example: 'test_group'
-
+ example: "test_group"
diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py
index 07b450dd33e..2bd0ce1b09f 100644
--- a/homeassistant/components/gtfs/sensor.py
+++ b/homeassistant/components/gtfs/sensor.py
@@ -143,7 +143,7 @@ def get_next_departure(
tomorrow_where = f"OR calendar.{tomorrow_name} = 1"
tomorrow_order = f"calendar.{tomorrow_name} DESC,"
- sql_query = """
+ sql_query = f"""
SELECT trip.trip_id, trip.route_id,
time(origin_stop_time.arrival_time) AS origin_arrival_time,
time(origin_stop_time.departure_time) AS origin_depart_time,
@@ -162,8 +162,8 @@ def get_next_departure(
destination_stop_time.stop_headsign AS dest_stop_headsign,
destination_stop_time.stop_sequence AS dest_stop_sequence,
destination_stop_time.timepoint AS dest_stop_timepoint,
- calendar.{yesterday_name} AS yesterday,
- calendar.{today_name} AS today,
+ calendar.{yesterday.strftime("%A").lower()} AS yesterday,
+ calendar.{now.strftime("%A").lower()} AS today,
{tomorrow_select}
calendar.start_date AS start_date,
calendar.end_date AS end_date
@@ -178,8 +178,8 @@ def get_next_departure(
ON trip.trip_id = destination_stop_time.trip_id
INNER JOIN stops end_station
ON destination_stop_time.stop_id = end_station.stop_id
- WHERE (calendar.{yesterday_name} = 1
- OR calendar.{today_name} = 1
+ WHERE (calendar.{yesterday.strftime("%A").lower()} = 1
+ OR calendar.{now.strftime("%A").lower()} = 1
{tomorrow_where}
)
AND start_station.stop_id = :origin_station_id
@@ -187,18 +187,12 @@ def get_next_departure(
AND origin_stop_sequence < dest_stop_sequence
AND calendar.start_date <= :today
AND calendar.end_date >= :today
- ORDER BY calendar.{yesterday_name} DESC,
- calendar.{today_name} DESC,
+ ORDER BY calendar.{yesterday.strftime("%A").lower()} DESC,
+ calendar.{now.strftime("%A").lower()} DESC,
{tomorrow_order}
origin_stop_time.departure_time
LIMIT :limit
- """.format(
- yesterday_name=yesterday.strftime("%A").lower(),
- today_name=now.strftime("%A").lower(),
- tomorrow_select=tomorrow_select,
- tomorrow_where=tomorrow_where,
- tomorrow_order=tomorrow_order,
- )
+ """
result = schedule.engine.execute(
text(sql_query),
origin_station_id=start_station_id,
@@ -220,7 +214,7 @@ def get_next_departure(
if yesterday_start is None:
yesterday_start = row["origin_depart_date"]
if yesterday_start != row["origin_depart_date"]:
- idx = "{} {}".format(now_date, row["origin_depart_time"])
+ idx = f"{now_date} {row['origin_depart_time']}"
timetable[idx] = {**row, **extras}
yesterday_last = idx
@@ -233,7 +227,7 @@ def get_next_departure(
idx_prefix = now_date
else:
idx_prefix = tomorrow_date
- idx = "{} {}".format(idx_prefix, row["origin_depart_time"])
+ idx = f"{idx_prefix} {row['origin_depart_time']}"
timetable[idx] = {**row, **extras}
today_last = idx
@@ -247,7 +241,7 @@ def get_next_departure(
tomorrow_start = row["origin_depart_date"]
extras["first"] = True
if tomorrow_start == row["origin_depart_date"]:
- idx = "{} {}".format(tomorrow_date, row["origin_depart_time"])
+ idx = f"{tomorrow_date} {row['origin_depart_time']}"
timetable[idx] = {**row, **extras}
# Flag last departures.
@@ -273,24 +267,27 @@ def get_next_departure(
origin_arrival = now
if item["origin_arrival_time"] > item["origin_depart_time"]:
origin_arrival -= datetime.timedelta(days=1)
- origin_arrival_time = "{} {}".format(
- origin_arrival.strftime(dt_util.DATE_STR_FORMAT), item["origin_arrival_time"]
+ origin_arrival_time = (
+ f"{origin_arrival.strftime(dt_util.DATE_STR_FORMAT)} "
+ f"{item['origin_arrival_time']}"
)
- origin_depart_time = "{} {}".format(now_date, item["origin_depart_time"])
+ origin_depart_time = f"{now_date} {item['origin_depart_time']}"
dest_arrival = now
if item["dest_arrival_time"] < item["origin_depart_time"]:
dest_arrival += datetime.timedelta(days=1)
- dest_arrival_time = "{} {}".format(
- dest_arrival.strftime(dt_util.DATE_STR_FORMAT), item["dest_arrival_time"]
+ dest_arrival_time = (
+ f"{dest_arrival.strftime(dt_util.DATE_STR_FORMAT)} "
+ f"{item['dest_arrival_time']}"
)
dest_depart = dest_arrival
if item["dest_depart_time"] < item["dest_arrival_time"]:
dest_depart += datetime.timedelta(days=1)
- dest_depart_time = "{} {}".format(
- dest_depart.strftime(dt_util.DATE_STR_FORMAT), item["dest_depart_time"]
+ dest_depart_time = (
+ f"{dest_depart.strftime(dt_util.DATE_STR_FORMAT)} "
+ f"{item['dest_depart_time']}"
)
depart_time = dt_util.parse_datetime(origin_depart_time)
@@ -511,15 +508,13 @@ class GTFSDepartureSensor(Entity):
else:
self._icon = ICON
- name = "{agency} {origin} to {destination} next departure"
- if not self._departure:
- name = "{default}"
- self._name = self._custom_name or name.format(
- agency=getattr(self._agency, "agency_name", DEFAULT_NAME),
- default=DEFAULT_NAME,
- origin=self.origin,
- destination=self.destination,
+ name = (
+ f"{getattr(self._agency, 'agency_name', DEFAULT_NAME)} "
+ f"{self.origin} to {self.destination} next departure"
)
+ if not self._departure:
+ name = f"{DEFAULT_NAME}"
+ self._name = self._custom_name or name
def update_attributes(self) -> None:
"""Update state attributes."""
diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py
index 52326555aab..78c47bf9635 100644
--- a/homeassistant/components/habitica/__init__.py
+++ b/homeassistant/components/habitica/__init__.py
@@ -80,7 +80,7 @@ SERVICE_API_CALL = "api_call"
ATTR_NAME = CONF_NAME
ATTR_PATH = CONF_PATH
ATTR_ARGS = "args"
-EVENT_API_CALL_SUCCESS = "{0}_{1}_{2}".format(DOMAIN, SERVICE_API_CALL, "success")
+EVENT_API_CALL_SUCCESS = f"{DOMAIN}_{SERVICE_API_CALL}_success"
SERVICE_API_CALL_SCHEMA = vol.Schema(
{
diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py
index bcc9d72ad08..126ce0ff992 100644
--- a/homeassistant/components/harmony/remote.py
+++ b/homeassistant/components/harmony/remote.py
@@ -111,9 +111,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
activity,
)
- harmony_conf_file = hass.config.path(
- "{}{}{}".format("harmony_", slugify(name), ".conf")
- )
+ harmony_conf_file = hass.config.path(f"harmony_{slugify(name)}.conf")
try:
device = HarmonyRemote(
name, address, port, activity, harmony_conf_file, delay_secs
diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py
index cc03f26085c..bcb751faa64 100644
--- a/homeassistant/components/hassio/__init__.py
+++ b/homeassistant/components/hassio/__init__.py
@@ -190,16 +190,15 @@ async def async_setup(hass, config):
hass.http.register_view(HassIOView(host, websession))
- if "frontend" in hass.config.components:
- await hass.components.panel_custom.async_register_panel(
- frontend_url_path="hassio",
- webcomponent_name="hassio-main",
- sidebar_title="Supervisor",
- sidebar_icon="hass:home-assistant",
- js_url="/api/hassio/app/entrypoint.js",
- embed_iframe=True,
- require_admin=True,
- )
+ await hass.components.panel_custom.async_register_panel(
+ frontend_url_path="hassio",
+ webcomponent_name="hassio-main",
+ sidebar_title="Supervisor",
+ sidebar_icon="hass:home-assistant",
+ js_url="/api/hassio/app/entrypoint.js",
+ embed_iframe=True,
+ require_admin=True,
+ )
await hassio.update_hass_api(config.get("http", {}), refresh_token)
diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json
index d3dd7dc9c94..cd004db4c93 100644
--- a/homeassistant/components/hassio/manifest.json
+++ b/homeassistant/components/hassio/manifest.json
@@ -3,6 +3,7 @@
"name": "Hass.io",
"documentation": "https://www.home-assistant.io/hassio",
"requirements": [],
- "dependencies": ["http", "panel_custom"],
+ "dependencies": ["http"],
+ "after_dependencies": ["panel_custom"],
"codeowners": ["@home-assistant/hass-io"]
}
diff --git a/homeassistant/components/haveibeenpwned/sensor.py b/homeassistant/components/haveibeenpwned/sensor.py
index 99f94499478..00a39aae8f4 100644
--- a/homeassistant/components/haveibeenpwned/sensor.py
+++ b/homeassistant/components/haveibeenpwned/sensor.py
@@ -81,13 +81,11 @@ class HaveIBeenPwnedSensor(Entity):
return val
for idx, value in enumerate(self._data.data[self._email]):
- tmpname = "breach {}".format(idx + 1)
- tmpvalue = "{} {}".format(
- value["Title"],
- dt_util.as_local(dt_util.parse_datetime(value["AddedDate"])).strftime(
- DATE_STR_FORMAT
- ),
+ tmpname = f"breach {idx + 1}"
+ datetime_local = dt_util.as_local(
+ dt_util.parse_datetime(value["AddedDate"])
)
+ tmpvalue = f"{value['Title']} {datetime_local.strftime(DATE_STR_FORMAT)}"
val[tmpname] = tmpvalue
return val
diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py
index f7e1ce5bc58..53c65a6ab07 100644
--- a/homeassistant/components/heos/__init__.py
+++ b/homeassistant/components/heos/__init__.py
@@ -52,9 +52,9 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType):
# Check if host needs to be updated
entry = entries[0]
if entry.data[CONF_HOST] != host:
- entry.data[CONF_HOST] = host
- entry.title = format_title(host)
- hass.config_entries.async_update_entry(entry)
+ hass.config_entries.async_update_entry(
+ entry, title=format_title(host), data={**entry.data, CONF_HOST: host}
+ )
return True
diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py
index 7e7fe067874..91dbc19ac95 100644
--- a/homeassistant/components/heos/config_flow.py
+++ b/homeassistant/components/heos/config_flow.py
@@ -27,9 +27,7 @@ class HeosFlowHandler(config_entries.ConfigFlow):
"""Handle a discovered Heos device."""
# Store discovered host
hostname = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname
- friendly_name = "{} ({})".format(
- discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME], hostname
- )
+ friendly_name = f"{discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME]} ({hostname})"
self.hass.data.setdefault(DATA_DISCOVERED_HOSTS, {})
self.hass.data[DATA_DISCOVERED_HOSTS][friendly_name] = hostname
# Abort if other flows in progress or an entry already exists
diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py
index 316e73dc096..4c7652484d6 100644
--- a/homeassistant/components/here_travel_time/sensor.py
+++ b/homeassistant/components/here_travel_time/sensor.py
@@ -18,6 +18,7 @@ from homeassistant.const import (
CONF_UNIT_SYSTEM_IMPERIAL,
CONF_UNIT_SYSTEM_METRIC,
EVENT_HOMEASSISTANT_START,
+ TIME_MINUTES,
)
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import location
@@ -85,8 +86,6 @@ ATTR_DURATION_IN_TRAFFIC = "duration_in_traffic"
ATTR_ORIGIN_NAME = "origin_name"
ATTR_DESTINATION_NAME = "destination_name"
-UNIT_OF_MEASUREMENT = "min"
-
SCAN_INTERVAL = timedelta(minutes=5)
NO_ROUTE_ERROR_MESSAGE = "HERE could not find a route based on the input"
@@ -209,7 +208,7 @@ class HERETravelTimeSensor(Entity):
self._origin_entity_id = origin_entity_id
self._destination_entity_id = destination_entity_id
self._here_data = here_data
- self._unit_of_measurement = UNIT_OF_MEASUREMENT
+ self._unit_of_measurement = TIME_MINUTES
self._attrs = {
ATTR_UNIT_SYSTEM: self._here_data.units,
ATTR_MODE: self._here_data.travel_mode,
@@ -316,7 +315,7 @@ class HERETravelTimeSensor(Entity):
return self._get_location_from_attributes(entity)
# Check if device is in a zone
- zone_entity = self.hass.states.get("zone.{}".format(entity.state))
+ zone_entity = self.hass.states.get(f"zone.{entity.state}")
if location.has_location(zone_entity):
_LOGGER.debug(
"%s is in %s, getting zone location", entity_id, zone_entity.entity_id
@@ -349,7 +348,7 @@ class HERETravelTimeSensor(Entity):
def _get_location_from_attributes(entity: State) -> str:
"""Get the lat/long string from an entities attributes."""
attr = entity.attributes
- return "{},{}".format(attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE))
+ return f"{attr.get(ATTR_LATITUDE)},{attr.get(ATTR_LONGITUDE)}"
class HERETravelTimeData:
diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py
index 9db91217300..140f6908dce 100644
--- a/homeassistant/components/hikvision/binary_sensor.py
+++ b/homeassistant/components/hikvision/binary_sensor.py
@@ -109,7 +109,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
for channel in channel_list:
# Build sensor name, then parse customize config.
if data.type == "NVR":
- sensor_name = "{}_{}".format(sensor.replace(" ", "_"), channel[1])
+ sensor_name = f"{sensor.replace(' ', '_')}_{channel[1]}"
else:
sensor_name = sensor.replace(" ", "_")
diff --git a/homeassistant/components/history_graph/__init__.py b/homeassistant/components/history_graph/__init__.py
deleted file mode 100644
index e132b1d5d4c..00000000000
--- a/homeassistant/components/history_graph/__init__.py
+++ /dev/null
@@ -1,84 +0,0 @@
-"""Support to graphs card in the UI."""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.const import ATTR_ENTITY_ID, CONF_ENTITIES, CONF_NAME
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_component import EntityComponent
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = "history_graph"
-
-CONF_HOURS_TO_SHOW = "hours_to_show"
-CONF_REFRESH = "refresh"
-ATTR_HOURS_TO_SHOW = CONF_HOURS_TO_SHOW
-ATTR_REFRESH = CONF_REFRESH
-
-
-GRAPH_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_ENTITIES): cv.entity_ids,
- vol.Optional(CONF_NAME): cv.string,
- vol.Optional(CONF_HOURS_TO_SHOW, default=24): vol.Range(min=1),
- vol.Optional(CONF_REFRESH, default=0): vol.Range(min=0),
- }
-)
-
-
-CONFIG_SCHEMA = vol.Schema(
- {DOMAIN: cv.schema_with_slug_keys(GRAPH_SCHEMA)}, extra=vol.ALLOW_EXTRA
-)
-
-
-async def async_setup(hass, config):
- """Load graph configurations."""
- _LOGGER.warning(
- "The history_graph integration has been deprecated and is pending for removal "
- "in Home Assistant 0.107.0."
- )
-
- component = EntityComponent(_LOGGER, DOMAIN, hass)
- graphs = []
-
- for object_id, cfg in config[DOMAIN].items():
- name = cfg.get(CONF_NAME, object_id)
- graph = HistoryGraphEntity(name, cfg)
- graphs.append(graph)
-
- await component.async_add_entities(graphs)
-
- return True
-
-
-class HistoryGraphEntity(Entity):
- """Representation of a graph entity."""
-
- def __init__(self, name, cfg):
- """Initialize the graph."""
- self._name = name
- self._hours = cfg[CONF_HOURS_TO_SHOW]
- self._refresh = cfg[CONF_REFRESH]
- self._entities = cfg[CONF_ENTITIES]
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def name(self):
- """Return the name of the entity."""
- return self._name
-
- @property
- def state_attributes(self):
- """Return the state attributes."""
- attrs = {
- ATTR_HOURS_TO_SHOW: self._hours,
- ATTR_REFRESH: self._refresh,
- ATTR_ENTITY_ID: self._entities,
- }
- return attrs
diff --git a/homeassistant/components/history_graph/manifest.json b/homeassistant/components/history_graph/manifest.json
deleted file mode 100644
index e34907d05ce..00000000000
--- a/homeassistant/components/history_graph/manifest.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "domain": "history_graph",
- "name": "History Graph",
- "documentation": "https://www.home-assistant.io/integrations/history_graph",
- "requirements": [],
- "dependencies": ["history"],
- "codeowners": ["@andrey-git"],
- "quality_scale": "internal"
-}
diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py
index 3eb604b3957..48d65145219 100644
--- a/homeassistant/components/history_stats/sensor.py
+++ b/homeassistant/components/history_stats/sensor.py
@@ -13,6 +13,8 @@ from homeassistant.const import (
CONF_STATE,
CONF_TYPE,
EVENT_HOMEASSISTANT_START,
+ TIME_HOURS,
+ UNIT_PERCENTAGE,
)
from homeassistant.core import callback
from homeassistant.exceptions import TemplateError
@@ -35,7 +37,11 @@ CONF_TYPE_COUNT = "count"
CONF_TYPE_KEYS = [CONF_TYPE_TIME, CONF_TYPE_RATIO, CONF_TYPE_COUNT]
DEFAULT_NAME = "unnamed statistics"
-UNITS = {CONF_TYPE_TIME: "h", CONF_TYPE_RATIO: "%", CONF_TYPE_COUNT: ""}
+UNITS = {
+ CONF_TYPE_TIME: TIME_HOURS,
+ CONF_TYPE_RATIO: UNIT_PERCENTAGE,
+ CONF_TYPE_COUNT: "",
+}
ICON = "mdi:chart-line"
ATTR_VALUE = "value"
diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py
index 976821513b6..edd3388e74f 100644
--- a/homeassistant/components/hive/__init__.py
+++ b/homeassistant/components/hive/__init__.py
@@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__)
DOMAIN = "hive"
DATA_HIVE = "data_hive"
-SERVICES = ["Heating", "HotWater"]
+SERVICES = ["Heating", "HotWater", "TRV"]
SERVICE_BOOST_HOT_WATER = "boost_hot_water"
SERVICE_BOOST_HEATING = "boost_heating"
ATTR_TIME_PERIOD = "time_period"
diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json
index 6572b0dbda2..96563d5ab3d 100644
--- a/homeassistant/components/hive/manifest.json
+++ b/homeassistant/components/hive/manifest.json
@@ -2,7 +2,7 @@
"domain": "hive",
"name": "Hive",
"documentation": "https://www.home-assistant.io/integrations/hive",
- "requirements": ["pyhiveapi==0.2.19.3"],
+ "requirements": ["pyhiveapi==0.2.20.1"],
"dependencies": [],
"codeowners": ["@Rendili", "@KJonline"]
}
diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py
index 5ab16ed17e6..1750e9b0ff4 100644
--- a/homeassistant/components/hlk_sw16/__init__.py
+++ b/homeassistant/components/hlk_sw16/__init__.py
@@ -30,8 +30,6 @@ DEFAULT_PORT = 8080
DOMAIN = "hlk_sw16"
-SIGNAL_AVAILABILITY = "hlk_sw16_device_available_{}"
-
SWITCH_SCHEMA = vol.Schema({vol.Optional(CONF_NAME): cv.string})
RELAY_ID = vol.All(
@@ -74,13 +72,13 @@ async def async_setup(hass, config):
def disconnected():
"""Schedule reconnect after connection has been lost."""
_LOGGER.warning("HLK-SW16 %s disconnected", device)
- async_dispatcher_send(hass, SIGNAL_AVAILABILITY.format(device), False)
+ async_dispatcher_send(hass, f"hlk_sw16_device_available_{device}", False)
@callback
def reconnected():
"""Schedule reconnect after connection has been lost."""
_LOGGER.warning("HLK-SW16 %s connected", device)
- async_dispatcher_send(hass, SIGNAL_AVAILABILITY.format(device), True)
+ async_dispatcher_send(hass, f"hlk_sw16_device_available_{device}", True)
async def connect():
"""Set up connection and hook it into HA for reconnect/shutdown."""
@@ -168,6 +166,6 @@ class SW16Device(Entity):
self._is_on = await self._client.status(self._device_port)
async_dispatcher_connect(
self.hass,
- SIGNAL_AVAILABILITY.format(self._device_id),
+ f"hlk_sw16_device_available_{self._device_id}",
self._availability_callback,
)
diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py
index ca5a601068a..c46bd754319 100644
--- a/homeassistant/components/homekit/__init__.py
+++ b/homeassistant/components/homekit/__init__.py
@@ -23,6 +23,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entityfilter import FILTER_SCHEMA
@@ -236,7 +237,7 @@ def get_accessory(hass, driver, state, aid, config):
TEMP_FAHRENHEIT,
):
a_type = "TemperatureSensor"
- elif device_class == DEVICE_CLASS_HUMIDITY and unit == "%":
+ elif device_class == DEVICE_CLASS_HUMIDITY and unit == UNIT_PERCENTAGE:
a_type = "HumiditySensor"
elif device_class == DEVICE_CLASS_PM25 or DEVICE_CLASS_PM25 in state.entity_id:
a_type = "AirQualitySensor"
diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py
index 3c5dce4fa7a..e07e9cb4749 100644
--- a/homeassistant/components/homekit/type_media_players.py
+++ b/homeassistant/components/homekit/type_media_players.py
@@ -146,7 +146,7 @@ class MediaPlayer(HomeAccessory):
def generate_service_name(self, mode):
"""Generate name for individual service."""
- return "{} {}".format(self.display_name, MODE_FRIENDLY_NAME[mode])
+ return f"{self.display_name} {MODE_FRIENDLY_NAME[mode]}"
def set_on_off(self, value):
"""Move switch state to value if call came from HomeKit."""
@@ -287,7 +287,7 @@ class TelevisionMediaPlayer(HomeAccessory):
)
serv_tv.add_linked_service(serv_speaker)
- name = "{} {}".format(self.display_name, "Volume")
+ name = f"{self.display_name} Volume"
serv_speaker.configure_char(CHAR_NAME, value=name)
serv_speaker.configure_char(CHAR_ACTIVE, value=1)
diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py
index 0fe97cfca63..c12f49e1b9c 100644
--- a/homeassistant/components/homekit/util.py
+++ b/homeassistant/components/homekit/util.py
@@ -102,9 +102,7 @@ def validate_entity_config(values):
domain, _ = split_entity_id(entity)
if not isinstance(config, dict):
- raise vol.Invalid(
- "The configuration for {} must be a dictionary.".format(entity)
- )
+ raise vol.Invalid(f"The configuration for {entity} must be a dictionary.")
if domain in ("alarm_control_panel", "lock"):
config = CODE_SCHEMA(config)
@@ -212,8 +210,8 @@ def show_setup_message(hass, pincode):
pin = pincode.decode()
_LOGGER.info("Pincode: %s", pin)
message = (
- "To set up Home Assistant in the Home App, enter the "
- "following code:\n### {}".format(pin)
+ f"To set up Home Assistant in the Home App, enter the "
+ f"following code:\n### {pin}"
)
hass.components.persistent_notification.create(
message, "HomeKit Setup", HOMEKIT_NOTIFY_ID
diff --git a/homeassistant/components/homekit_controller/.translations/en.json b/homeassistant/components/homekit_controller/.translations/en.json
index 72aa720b449..eb994289a62 100644
--- a/homeassistant/components/homekit_controller/.translations/en.json
+++ b/homeassistant/components/homekit_controller/.translations/en.json
@@ -14,7 +14,7 @@
"busy_error": "Device refused to add pairing as it is already pairing with another controller.",
"max_peers_error": "Device refused to add pairing as it has no free pairing storage.",
"max_tries_error": "Device refused to add pairing as it has received more than 100 unsuccessful authentication attempts.",
- "pairing_failed": "An unhandled error occured while attempting to pair with this device. This may be a temporary failure or your device may not be supported currently.",
+ "pairing_failed": "An unhandled error occurred while attempting to pair with this device. This may be a temporary failure or your device may not be supported currently.",
"unable_to_pair": "Unable to pair, please try again.",
"unknown_error": "Device reported an unknown error. Pairing failed."
},
diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py
index b2275282293..2089471f288 100644
--- a/homeassistant/components/homekit_controller/__init__.py
+++ b/homeassistant/components/homekit_controller/__init__.py
@@ -1,16 +1,23 @@
"""Support for Homekit device discovery."""
import logging
+import os
+from typing import Any, Dict
-import homekit
-from homekit.model.characteristics import CharacteristicsTypes
+import aiohomekit
+from aiohomekit.model import Accessory
+from aiohomekit.model.characteristics import (
+ Characteristic,
+ CharacteristicPermissions,
+ CharacteristicsTypes,
+)
+from aiohomekit.model.services import Service, ServicesTypes
-from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import Entity
from .config_flow import normalize_hkid
-from .connection import HKDevice, get_accessory_information
+from .connection import HKDevice
from .const import CONTROLLER, DOMAIN, ENTITY_MAP, KNOWN_DEVICES
from .storage import EntityMapStorage
@@ -31,29 +38,65 @@ class HomeKitEntity(Entity):
self._aid = devinfo["aid"]
self._iid = devinfo["iid"]
self._features = 0
- self._chars = {}
self.setup()
self._signals = []
+ @property
+ def accessory(self) -> Accessory:
+ """Return an Accessory model that this entity is attached to."""
+ return self._accessory.entity_map.aid(self._aid)
+
+ @property
+ def accessory_info(self) -> Service:
+ """Information about the make and model of an accessory."""
+ return self.accessory.services.first(
+ service_type=ServicesTypes.ACCESSORY_INFORMATION
+ )
+
+ @property
+ def service(self) -> Service:
+ """Return a Service model that this entity is attached to."""
+ return self.accessory.services.iid(self._iid)
+
async def async_added_to_hass(self):
"""Entity added to hass."""
self._signals.append(
self.hass.helpers.dispatcher.async_dispatcher_connect(
- self._accessory.signal_state_updated, self.async_state_changed
+ self._accessory.signal_state_updated, self.async_write_ha_state
)
)
self._accessory.add_pollable_characteristics(self.pollable_characteristics)
+ self._accessory.add_watchable_characteristics(self.watchable_characteristics)
async def async_will_remove_from_hass(self):
"""Prepare to be removed from hass."""
self._accessory.remove_pollable_characteristics(self._aid)
+ self._accessory.remove_watchable_characteristics(self._aid)
for signal_remove in self._signals:
signal_remove()
self._signals.clear()
+ async def async_put_characteristics(self, characteristics: Dict[str, Any]):
+ """
+ Write characteristics to the device.
+
+ A characteristic type is unique within a service, but in order to write
+ to a named characteristic on a bridge we need to turn its type into
+ an aid and iid, and send it as a list of tuples, which is what this
+ helper does.
+
+ E.g. you can do:
+
+ await entity.async_put_characteristics({
+ CharacteristicsTypes.ON: True
+ })
+ """
+ payload = self.service.build_update(characteristics)
+ return await self._accessory.put_characteristics(payload)
+
@property
def should_poll(self) -> bool:
"""Return False.
@@ -64,95 +107,49 @@ class HomeKitEntity(Entity):
def setup(self):
"""Configure an entity baed on its HomeKit characteristics metadata."""
- accessories = self._accessory.accessories
-
- get_uuid = CharacteristicsTypes.get_uuid
- characteristic_types = [get_uuid(c) for c in self.get_characteristic_types()]
-
self.pollable_characteristics = []
- self._chars = {}
- self._char_names = {}
+ self.watchable_characteristics = []
- for accessory in accessories:
- if accessory["aid"] != self._aid:
- continue
- self._accessory_info = get_accessory_information(accessory)
- for service in accessory["services"]:
- if service["iid"] != self._iid:
- continue
- for char in service["characteristics"]:
- try:
- uuid = CharacteristicsTypes.get_uuid(char["type"])
- except KeyError:
- # If a KeyError is raised its a non-standard
- # characteristic. We must ignore it in this case.
- continue
- if uuid not in characteristic_types:
- continue
- self._setup_characteristic(char)
+ char_types = self.get_characteristic_types()
- def _setup_characteristic(self, char):
+ # Setup events and/or polling for characteristics directly attached to this entity
+ for char in self.service.characteristics.filter(char_types=char_types):
+ self._setup_characteristic(char)
+
+ # Setup events and/or polling for characteristics attached to sub-services of this
+ # entity (like an INPUT_SOURCE).
+ for service in self.accessory.services.filter(parent_service=self.service):
+ for char in service.characteristics.filter(char_types=char_types):
+ self._setup_characteristic(char)
+
+ def _setup_characteristic(self, char: Characteristic):
"""Configure an entity based on a HomeKit characteristics metadata."""
# Build up a list of (aid, iid) tuples to poll on update()
- self.pollable_characteristics.append((self._aid, char["iid"]))
+ if CharacteristicPermissions.paired_read in char.perms:
+ self.pollable_characteristics.append((self._aid, char.iid))
- # Build a map of ctype -> iid
- short_name = CharacteristicsTypes.get_short(char["type"])
- self._chars[short_name] = char["iid"]
- self._char_names[char["iid"]] = short_name
+ # Build up a list of (aid, iid) tuples to subscribe to
+ 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(short_name)
+ 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)
-
- def get_hk_char_value(self, characteristic_type):
- """Return the value for a given characteristic type enum."""
- state = self._accessory.current_state.get(self._aid)
- if not state:
- return None
- char = self._chars.get(CharacteristicsTypes.get_short(characteristic_type))
- if not char:
- return None
- return state.get(char, {}).get("value")
-
- @callback
- def async_state_changed(self):
- """Collect new data from bridge and update the entity state in hass."""
- accessory_state = self._accessory.current_state.get(self._aid, {})
- for iid, result in accessory_state.items():
- # No value so don't process this result
- if "value" not in result:
- continue
-
- # Unknown iid - this is probably for a sibling service that is part
- # of the same physical accessory. Ignore it.
- if iid not in self._char_names:
- continue
-
- # Callback to update the entity with this characteristic value
- char_name = escape_characteristic_name(self._char_names[iid])
- update_fn = getattr(self, f"_update_{char_name}", None)
- if not update_fn:
- continue
-
- update_fn(result["value"])
-
- self.async_write_ha_state()
+ setup_fn(char.to_accessory_and_service_list())
@property
- def unique_id(self):
+ def unique_id(self) -> str:
"""Return the ID of this device."""
- serial = self._accessory_info["serial-number"]
+ serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER)
return f"homekit-{serial}-{self._iid}"
@property
- def name(self):
+ def name(self) -> str:
"""Return the name of the device if any."""
- return self._accessory_info.get("name")
+ return self.accessory_info.value(CharacteristicsTypes.NAME)
@property
def available(self) -> bool:
@@ -162,14 +159,15 @@ class HomeKitEntity(Entity):
@property
def device_info(self):
"""Return the device info."""
- accessory_serial = self._accessory_info["serial-number"]
+ info = self.accessory_info
+ accessory_serial = info.value(CharacteristicsTypes.SERIAL_NUMBER)
device_info = {
"identifiers": {(DOMAIN, "serial-number", accessory_serial)},
- "name": self._accessory_info["name"],
- "manufacturer": self._accessory_info.get("manufacturer", ""),
- "model": self._accessory_info.get("model", ""),
- "sw_version": self._accessory_info.get("firmware.revision", ""),
+ "name": info.value(CharacteristicsTypes.NAME),
+ "manufacturer": info.value(CharacteristicsTypes.MANUFACTURER, ""),
+ "model": info.value(CharacteristicsTypes.MODEL, ""),
+ "sw_version": info.value(CharacteristicsTypes.FIRMWARE_REVISION, ""),
}
# Some devices only have a single accessory - we don't add a
@@ -223,9 +221,19 @@ async def async_setup(hass, config):
map_storage = hass.data[ENTITY_MAP] = EntityMapStorage(hass)
await map_storage.async_initialize()
- hass.data[CONTROLLER] = homekit.Controller()
+ hass.data[CONTROLLER] = aiohomekit.Controller()
hass.data[KNOWN_DEVICES] = {}
+ dothomekit_dir = hass.config.path(".homekit")
+ if os.path.exists(dothomekit_dir):
+ _LOGGER.warning(
+ (
+ "Legacy homekit_controller state found in %s. Support for reading "
+ "the folder is deprecated and will be removed in 0.109.0."
+ ),
+ dothomekit_dir,
+ )
+
return True
diff --git a/homeassistant/components/homekit_controller/air_quality.py b/homeassistant/components/homekit_controller/air_quality.py
index 41194cb340c..999980ad60c 100644
--- a/homeassistant/components/homekit_controller/air_quality.py
+++ b/homeassistant/components/homekit_controller/air_quality.py
@@ -1,5 +1,5 @@
"""Support for HomeKit Controller air quality sensors."""
-from homekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.air_quality import AirQualityEntity
from homeassistant.core import callback
@@ -34,38 +34,38 @@ class HomeAirQualitySensor(HomeKitEntity, AirQualityEntity):
@property
def particulate_matter_2_5(self):
"""Return the particulate matter 2.5 level."""
- return self.get_hk_char_value(CharacteristicsTypes.DENSITY_PM25)
+ return self.service.value(CharacteristicsTypes.DENSITY_PM25)
@property
def particulate_matter_10(self):
"""Return the particulate matter 10 level."""
- return self.get_hk_char_value(CharacteristicsTypes.DENSITY_PM10)
+ return self.service.value(CharacteristicsTypes.DENSITY_PM10)
@property
def ozone(self):
"""Return the O3 (ozone) level."""
- return self.get_hk_char_value(CharacteristicsTypes.DENSITY_OZONE)
+ return self.service.value(CharacteristicsTypes.DENSITY_OZONE)
@property
def sulphur_dioxide(self):
"""Return the SO2 (sulphur dioxide) level."""
- return self.get_hk_char_value(CharacteristicsTypes.DENSITY_SO2)
+ return self.service.value(CharacteristicsTypes.DENSITY_SO2)
@property
def nitrogen_dioxide(self):
"""Return the NO2 (nitrogen dioxide) level."""
- return self.get_hk_char_value(CharacteristicsTypes.DENSITY_NO2)
+ return self.service.value(CharacteristicsTypes.DENSITY_NO2)
@property
def air_quality_text(self):
"""Return the Air Quality Index (AQI)."""
- air_quality = self.get_hk_char_value(CharacteristicsTypes.AIR_QUALITY)
+ air_quality = self.service.value(CharacteristicsTypes.AIR_QUALITY)
return AIR_QUALITY_TEXT.get(air_quality, "unknown")
@property
def volatile_organic_compounds(self):
"""Return the volatile organic compounds (VOC) level."""
- return self.get_hk_char_value(CharacteristicsTypes.DENSITY_VOC)
+ return self.service.value(CharacteristicsTypes.DENSITY_VOC)
@property
def device_state_attributes(self):
diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py
index f4f89507fca..9e712b4127f 100644
--- a/homeassistant/components/homekit_controller/alarm_control_panel.py
+++ b/homeassistant/components/homekit_controller/alarm_control_panel.py
@@ -1,7 +1,7 @@
"""Support for Homekit Alarm Control Panel."""
import logging
-from homekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.alarm_control_panel import AlarmControlPanel
from homeassistant.components.alarm_control_panel.const import (
@@ -60,12 +60,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel):
"""Representation of a Homekit Alarm Control Panel."""
- def __init__(self, *args):
- """Initialise the Alarm Control Panel."""
- super().__init__(*args)
- self._state = None
- self._battery_level = None
-
def get_characteristic_types(self):
"""Define the homekit characteristics the entity cares about."""
return [
@@ -74,12 +68,6 @@ class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel):
CharacteristicsTypes.BATTERY_LEVEL,
]
- def _update_security_system_state_current(self, value):
- self._state = CURRENT_STATE_MAP[value]
-
- def _update_battery_level(self, value):
- self._battery_level = value
-
@property
def icon(self):
"""Return icon."""
@@ -88,7 +76,9 @@ class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel):
@property
def state(self):
"""Return the state of the device."""
- return self._state
+ return CURRENT_STATE_MAP[
+ self.service.value(CharacteristicsTypes.SECURITY_SYSTEM_STATE_CURRENT)
+ ]
@property
def supported_features(self) -> int:
@@ -113,19 +103,17 @@ class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel):
async def set_alarm_state(self, state, code=None):
"""Send state command."""
- characteristics = [
- {
- "aid": self._aid,
- "iid": self._chars["security-system-state.target"],
- "value": TARGET_STATE_MAP[state],
- }
- ]
- await self._accessory.put_characteristics(characteristics)
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.SECURITY_SYSTEM_STATE_TARGET: TARGET_STATE_MAP[state]}
+ )
@property
def device_state_attributes(self):
"""Return the optional state attributes."""
- if self._battery_level is None:
- return None
+ attributes = {}
- return {ATTR_BATTERY_LEVEL: self._battery_level}
+ battery_level = self.service.value(CharacteristicsTypes.BATTERY_LEVEL)
+ if battery_level:
+ attributes[ATTR_BATTERY_LEVEL] = battery_level
+
+ return attributes
diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py
index 0a6a3fca1cf..39d0e19ba40 100644
--- a/homeassistant/components/homekit_controller/binary_sensor.py
+++ b/homeassistant/components/homekit_controller/binary_sensor.py
@@ -1,9 +1,12 @@
"""Support for Homekit motion sensors."""
import logging
-from homekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.binary_sensor import (
+ DEVICE_CLASS_MOTION,
+ DEVICE_CLASS_OCCUPANCY,
+ DEVICE_CLASS_OPENING,
DEVICE_CLASS_SMOKE,
BinarySensorDevice,
)
@@ -17,58 +20,42 @@ _LOGGER = logging.getLogger(__name__)
class HomeKitMotionSensor(HomeKitEntity, BinarySensorDevice):
"""Representation of a Homekit motion sensor."""
- def __init__(self, *args):
- """Initialise the entity."""
- super().__init__(*args)
- self._on = False
-
def get_characteristic_types(self):
"""Define the homekit characteristics the entity is tracking."""
return [CharacteristicsTypes.MOTION_DETECTED]
- def _update_motion_detected(self, value):
- self._on = value
-
@property
def device_class(self):
"""Define this binary_sensor as a motion sensor."""
- return "motion"
+ return DEVICE_CLASS_MOTION
@property
def is_on(self):
"""Has motion been detected."""
- return self._on
+ return self.service.value(CharacteristicsTypes.MOTION_DETECTED)
class HomeKitContactSensor(HomeKitEntity, BinarySensorDevice):
"""Representation of a Homekit contact sensor."""
- def __init__(self, *args):
- """Initialise the entity."""
- super().__init__(*args)
- self._state = None
-
def get_characteristic_types(self):
"""Define the homekit characteristics the entity is tracking."""
return [CharacteristicsTypes.CONTACT_STATE]
- def _update_contact_state(self, value):
- self._state = value
+ @property
+ def device_class(self):
+ """Define this binary_sensor as a opening sensor."""
+ return DEVICE_CLASS_OPENING
@property
def is_on(self):
"""Return true if the binary sensor is on/open."""
- return self._state == 1
+ return self.service.value(CharacteristicsTypes.CONTACT_STATE) == 1
class HomeKitSmokeSensor(HomeKitEntity, BinarySensorDevice):
"""Representation of a Homekit smoke sensor."""
- def __init__(self, *args):
- """Initialise the entity."""
- super().__init__(*args)
- self._state = None
-
@property
def device_class(self) -> str:
"""Return the class of this sensor."""
@@ -78,19 +65,35 @@ class HomeKitSmokeSensor(HomeKitEntity, BinarySensorDevice):
"""Define the homekit characteristics the entity is tracking."""
return [CharacteristicsTypes.SMOKE_DETECTED]
- def _update_smoke_detected(self, value):
- self._state = value
-
@property
def is_on(self):
"""Return true if smoke is currently detected."""
- return self._state == 1
+ return self.service.value(CharacteristicsTypes.SMOKE_DETECTED) == 1
+
+
+class HomeKitOccupancySensor(HomeKitEntity, BinarySensorDevice):
+ """Representation of a Homekit occupancy sensor."""
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this sensor."""
+ return DEVICE_CLASS_OCCUPANCY
+
+ def get_characteristic_types(self):
+ """Define the homekit characteristics the entity is tracking."""
+ return [CharacteristicsTypes.OCCUPANCY_DETECTED]
+
+ @property
+ def is_on(self):
+ """Return true if occupancy is currently detected."""
+ return self.service.value(CharacteristicsTypes.OCCUPANCY_DETECTED) == 1
ENTITY_TYPES = {
"motion": HomeKitMotionSensor,
"contact": HomeKitContactSensor,
"smoke": HomeKitSmokeSensor,
+ "occupancy": HomeKitOccupancySensor,
}
diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py
index bbef10d3204..133c100b125 100644
--- a/homeassistant/components/homekit_controller/climate.py
+++ b/homeassistant/components/homekit_controller/climate.py
@@ -1,7 +1,7 @@
"""Support for Homekit climate devices."""
import logging
-from homekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.climate import (
DEFAULT_MAX_HUMIDITY,
@@ -67,14 +67,7 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice):
def __init__(self, *args):
"""Initialise the device."""
- self._state = None
- self._target_mode = None
- self._current_mode = None
self._valid_modes = []
- self._current_temp = None
- self._target_temp = None
- self._current_humidity = None
- self._target_humidity = None
self._min_target_temp = None
self._max_target_temp = None
self._min_target_humidity = DEFAULT_MIN_HUMIDITY
@@ -130,71 +123,39 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice):
if "maxValue" in characteristic:
self._max_target_humidity = characteristic["maxValue"]
- def _update_heating_cooling_current(self, value):
- # This characteristic describes the current mode of a device,
- # e.g. a thermostat is "heating" a room to 75 degrees Fahrenheit.
- # Can be 0 - 2 (Off, Heat, Cool)
- self._current_mode = CURRENT_MODE_HOMEKIT_TO_HASS.get(value)
-
- def _update_heating_cooling_target(self, value):
- # This characteristic describes the target mode
- # E.g. should the device start heating a room if the temperature
- # falls below the target temperature.
- # Can be 0 - 3 (Off, Heat, Cool, Auto)
- self._target_mode = MODE_HOMEKIT_TO_HASS.get(value)
-
- def _update_temperature_current(self, value):
- self._current_temp = value
-
- def _update_temperature_target(self, value):
- self._target_temp = value
-
- def _update_relative_humidity_current(self, value):
- self._current_humidity = value
-
- def _update_relative_humidity_target(self, value):
- self._target_humidity = value
-
async def async_set_temperature(self, **kwargs):
"""Set new target temperature."""
temp = kwargs.get(ATTR_TEMPERATURE)
- characteristics = [
- {"aid": self._aid, "iid": self._chars["temperature.target"], "value": temp}
- ]
- await self._accessory.put_characteristics(characteristics)
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.TEMPERATURE_TARGET: temp}
+ )
async def async_set_humidity(self, humidity):
"""Set new target humidity."""
- characteristics = [
- {
- "aid": self._aid,
- "iid": self._chars["relative-humidity.target"],
- "value": humidity,
- }
- ]
- await self._accessory.put_characteristics(characteristics)
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET: humidity}
+ )
async def async_set_hvac_mode(self, hvac_mode):
"""Set new target operation mode."""
- characteristics = [
+ await self.async_put_characteristics(
{
- "aid": self._aid,
- "iid": self._chars["heating-cooling.target"],
- "value": MODE_HASS_TO_HOMEKIT[hvac_mode],
+ CharacteristicsTypes.HEATING_COOLING_TARGET: MODE_HASS_TO_HOMEKIT[
+ hvac_mode
+ ],
}
- ]
- await self._accessory.put_characteristics(characteristics)
+ )
@property
def current_temperature(self):
"""Return the current temperature."""
- return self._current_temp
+ return self.service.value(CharacteristicsTypes.TEMPERATURE_CURRENT)
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
- return self._target_temp
+ return self.service.value(CharacteristicsTypes.TEMPERATURE_TARGET)
@property
def min_temp(self):
@@ -213,12 +174,12 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice):
@property
def current_humidity(self):
"""Return the current humidity."""
- return self._current_humidity
+ return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT)
@property
def target_humidity(self):
"""Return the humidity we try to reach."""
- return self._target_humidity
+ return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET)
@property
def min_humidity(self):
@@ -233,12 +194,21 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice):
@property
def hvac_action(self):
"""Return the current running hvac operation."""
- return self._current_mode
+ # This characteristic describes the current mode of a device,
+ # e.g. a thermostat is "heating" a room to 75 degrees Fahrenheit.
+ # Can be 0 - 2 (Off, Heat, Cool)
+ value = self.service.value(CharacteristicsTypes.HEATING_COOLING_CURRENT)
+ return CURRENT_MODE_HOMEKIT_TO_HASS.get(value)
@property
def hvac_mode(self):
"""Return hvac operation ie. heat, cool mode."""
- return self._target_mode
+ # This characteristic describes the target mode
+ # E.g. should the device start heating a room if the temperature
+ # falls below the target temperature.
+ # Can be 0 - 3 (Off, Heat, Cool, Auto)
+ value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET)
+ return MODE_HOMEKIT_TO_HASS.get(value)
@property
def hvac_modes(self):
diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py
index 559e0b4a997..81dcfdc8f9a 100644
--- a/homeassistant/components/homekit_controller/config_flow.py
+++ b/homeassistant/components/homekit_controller/config_flow.py
@@ -4,8 +4,8 @@ import logging
import os
import re
-import homekit
-from homekit.controller.ip_implementation import IpPairing
+import aiohomekit
+from aiohomekit.controller.ip import IpPairing
import voluptuous as vol
from homeassistant import config_entries
@@ -58,7 +58,7 @@ def normalize_hkid(hkid):
def find_existing_host(hass, serial):
"""Return a set of the configured hosts."""
for entry in hass.config_entries.async_entries(DOMAIN):
- if entry.data["AccessoryPairingID"] == serial:
+ if entry.data.get("AccessoryPairingID") == serial:
return entry
@@ -72,8 +72,8 @@ def ensure_pin_format(pin):
"""
match = PIN_FORMAT.search(pin)
if not match:
- raise homekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}")
- return "{}-{}-{}".format(*match.groups())
+ raise aiohomekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}")
+ return "-".join(match.groups())
@config_entries.HANDLERS.register(DOMAIN)
@@ -81,14 +81,14 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow):
"""Handle a HomeKit config flow."""
VERSION = 1
- CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
def __init__(self):
"""Initialize the homekit_controller flow."""
self.model = None
self.hkid = None
self.devices = {}
- self.controller = homekit.Controller()
+ self.controller = aiohomekit.Controller()
self.finish_pairing = None
async def async_step_user(self, user_input=None):
@@ -97,22 +97,22 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow):
if user_input is not None:
key = user_input["device"]
- self.hkid = self.devices[key]["id"]
- self.model = self.devices[key]["md"]
+ self.hkid = self.devices[key].device_id
+ self.model = self.devices[key].info["md"]
await self.async_set_unique_id(
normalize_hkid(self.hkid), raise_on_progress=False
)
return await self.async_step_pair()
- all_hosts = await self.hass.async_add_executor_job(self.controller.discover, 5)
+ all_hosts = await self.controller.discover_ip()
self.devices = {}
for host in all_hosts:
- status_flags = int(host["sf"])
+ status_flags = int(host.info["sf"])
paired = not status_flags & 0x01
if paired:
continue
- self.devices[host["name"]] = host
+ self.devices[host.info["name"]] = host
if not self.devices:
return self.async_abort(reason="no_devices")
@@ -130,10 +130,11 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow):
unique_id = user_input["unique_id"]
await self.async_set_unique_id(unique_id)
- records = await self.hass.async_add_executor_job(self.controller.discover, 5)
- for record in records:
- if normalize_hkid(record["id"]) != unique_id:
+ devices = await self.controller.discover_ip(5)
+ for device in devices:
+ if normalize_hkid(device.device_id) != unique_id:
continue
+ record = device.info
return await self.async_step_zeroconf(
{
"host": record["address"],
@@ -201,6 +202,14 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow):
_LOGGER.debug("Discovered device %s (%s - %s)", name, model, hkid)
+ # Device isn't paired with us or anyone else.
+ # But we have a 'complete' config entry for it - that is probably
+ # invalid. Remove it automatically.
+ existing = find_existing_host(self.hass, hkid)
+ if not paired and existing:
+ await self.hass.config_entries.async_remove(existing.entry_id)
+
+ # Set unique-id and error out if it's already configured
await self.async_set_unique_id(normalize_hkid(hkid))
self._abort_if_unique_id_configured()
@@ -228,13 +237,6 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow):
if model in HOMEKIT_IGNORE:
return self.async_abort(reason="ignored_model")
- # Device isn't paired with us or anyone else.
- # But we have a 'complete' config entry for it - that is probably
- # invalid. Remove it automatically.
- existing = find_existing_host(self.hass, hkid)
- if existing:
- await self.hass.config_entries.async_remove(existing.entry_id)
-
self.model = model
self.hkid = hkid
@@ -248,17 +250,6 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow):
hkid = discovery_props["id"]
- existing = find_existing_host(self.hass, hkid)
- if existing:
- _LOGGER.info(
- (
- "Legacy configuration for homekit accessory %s"
- "not loaded as already migrated"
- ),
- hkid,
- )
- return self.async_abort(reason="already_configured")
-
_LOGGER.info(
(
"Legacy configuration %s for homekit"
@@ -295,55 +286,49 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow):
code = pair_info["pairing_code"]
try:
code = ensure_pin_format(code)
-
- await self.hass.async_add_executor_job(self.finish_pairing, code)
-
- pairing = self.controller.pairings.get(self.hkid)
- if pairing:
- return await self._entry_from_accessory(pairing)
-
- errors["pairing_code"] = "unable_to_pair"
- except homekit.exceptions.MalformedPinError:
+ pairing = await self.finish_pairing(code)
+ return await self._entry_from_accessory(pairing)
+ except aiohomekit.exceptions.MalformedPinError:
# Library claimed pin was invalid before even making an API call
errors["pairing_code"] = "authentication_error"
- except homekit.AuthenticationError:
+ except aiohomekit.AuthenticationError:
# PairSetup M4 - SRP proof failed
# PairSetup M6 - Ed25519 signature verification failed
# PairVerify M4 - Decryption failed
# PairVerify M4 - Device not recognised
# PairVerify M4 - Ed25519 signature verification failed
errors["pairing_code"] = "authentication_error"
- except homekit.UnknownError:
+ except aiohomekit.UnknownError:
# An error occurred on the device whilst performing this
# operation.
errors["pairing_code"] = "unknown_error"
- except homekit.MaxPeersError:
+ except aiohomekit.MaxPeersError:
# The device can't pair with any more accessories.
errors["pairing_code"] = "max_peers_error"
- except homekit.AccessoryNotFoundError:
+ except aiohomekit.AccessoryNotFoundError:
# Can no longer find the device on the network
return self.async_abort(reason="accessory_not_found_error")
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Pairing attempt failed with an unhandled exception")
errors["pairing_code"] = "pairing_failed"
- start_pairing = self.controller.start_pairing
+ discovery = await self.controller.find_ip_by_device_id(self.hkid)
+
try:
- self.finish_pairing = await self.hass.async_add_executor_job(
- start_pairing, self.hkid, self.hkid
- )
- except homekit.BusyError:
+ self.finish_pairing = await discovery.start_pairing(self.hkid)
+
+ except aiohomekit.BusyError:
# Already performing a pair setup operation with a different
# controller
errors["pairing_code"] = "busy_error"
- except homekit.MaxTriesError:
+ except aiohomekit.MaxTriesError:
# The accessory has received more than 100 unsuccessful auth
# attempts.
errors["pairing_code"] = "max_tries_error"
- except homekit.UnavailableError:
+ except aiohomekit.UnavailableError:
# The accessory is already paired - cannot try to pair again.
return self.async_abort(reason="already_paired")
- except homekit.AccessoryNotFoundError:
+ except aiohomekit.AccessoryNotFoundError:
# Can no longer find the device on the network
return self.async_abort(reason="accessory_not_found_error")
except Exception: # pylint: disable=broad-except
@@ -376,9 +361,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow):
# the same time.
accessories = pairing_data.pop("accessories", None)
if not accessories:
- accessories = await self.hass.async_add_executor_job(
- pairing.list_accessories_and_characteristics
- )
+ accessories = await pairing.list_accessories_and_characteristics()
bridge_info = get_bridge_information(accessories)
name = get_accessory_name(bridge_info)
diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py
index 11cb607842a..605253e6235 100644
--- a/homeassistant/components/homekit_controller/connection.py
+++ b/homeassistant/components/homekit_controller/connection.py
@@ -3,19 +3,19 @@ import asyncio
import datetime
import logging
-from homekit.controller.ip_implementation import IpPairing
-from homekit.exceptions import (
+from aiohomekit.exceptions import (
AccessoryDisconnectedError,
AccessoryNotFoundError,
EncryptionError,
)
-from homekit.model.characteristics import CharacteristicsTypes
-from homekit.model.services import ServicesTypes
+from aiohomekit.model import Accessories
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
from homeassistant.core import callback
from homeassistant.helpers.event import async_track_time_interval
-from .const import DOMAIN, ENTITY_MAP, HOMEKIT_ACCESSORY_DISPATCH
+from .const import CONTROLLER, DOMAIN, ENTITY_MAP, HOMEKIT_ACCESSORY_DISPATCH
DEFAULT_SCAN_INTERVAL = datetime.timedelta(seconds=60)
RETRY_INTERVAL = 60 # seconds
@@ -66,11 +66,15 @@ class HKDevice:
# don't want to mutate a dict owned by a config entry.
self.pairing_data = pairing_data.copy()
- self.pairing = IpPairing(self.pairing_data)
+ self.pairing = hass.data[CONTROLLER].load_pairing(
+ self.pairing_data["AccessoryPairingID"], self.pairing_data
+ )
- self.accessories = {}
+ self.accessories = None
self.config_num = 0
+ self.entity_map = Accessories()
+
# A list of callbacks that turn HK service metadata into entities
self.listeners = []
@@ -107,6 +111,10 @@ class HKDevice:
self._polling_lock = asyncio.Lock()
self._polling_lock_warned = False
+ self.watchable_characteristics = []
+
+ self.pairing.dispatcher_connect(self.process_new_events)
+
def add_pollable_characteristics(self, characteristics):
"""Add (aid, iid) pairs that we need to poll."""
self.pollable_characteristics.extend(characteristics)
@@ -117,6 +125,17 @@ class HKDevice:
char for char in self.pollable_characteristics if char[0] != accessory_id
]
+ def add_watchable_characteristics(self, characteristics):
+ """Add (aid, iid) pairs that we need to poll."""
+ self.watchable_characteristics.extend(characteristics)
+ self.hass.async_create_task(self.pairing.subscribe(characteristics))
+
+ def remove_watchable_characteristics(self, accessory_id):
+ """Remove all pollable characteristics by accessory id."""
+ self.watchable_characteristics = [
+ char for char in self.watchable_characteristics if char[0] != accessory_id
+ ]
+
@callback
def async_set_unavailable(self):
"""Mark state of all entities on this connection as unavailable."""
@@ -137,6 +156,8 @@ class HKDevice:
self.accessories = cache["accessories"]
self.config_num = cache["config_num"]
+ self.entity_map = Accessories.from_list(self.accessories)
+
self._polling_interval_remover = async_track_time_interval(
self.hass, self.async_update, DEFAULT_SCAN_INTERVAL
)
@@ -162,6 +183,9 @@ class HKDevice:
self.add_entities()
+ if self.watchable_characteristics:
+ await self.pairing.subscribe(self.watchable_characteristics)
+
await self.async_update()
return True
@@ -171,6 +195,8 @@ class HKDevice:
if self._polling_interval_remover:
self._polling_interval_remover()
+ await self.pairing.unsubscribe(self.watchable_characteristics)
+
unloads = []
for platform in self.platforms:
unloads.append(
@@ -186,15 +212,14 @@ class HKDevice:
async def async_refresh_entity_map(self, config_num):
"""Handle setup of a HomeKit accessory."""
try:
- async with self.pairing_lock:
- self.accessories = await self.hass.async_add_executor_job(
- self.pairing.list_accessories_and_characteristics
- )
+ self.accessories = await self.pairing.list_accessories_and_characteristics()
except AccessoryDisconnectedError:
# If we fail to refresh this data then we will naturally retry
# later when Bonjour spots c# is still not up to date.
return False
+ self.entity_map = Accessories.from_list(self.accessories)
+
self.hass.data[ENTITY_MAP].async_create_or_update_map(
self.unique_id, config_num, self.accessories
)
@@ -300,26 +325,21 @@ class HKDevice:
accessory = self.current_state.setdefault(aid, {})
accessory[cid] = value
+ # self.current_state will be replaced by entity_map in a future PR
+ # For now we update both
+ self.entity_map.process_changes(new_values_dict)
+
self.hass.helpers.dispatcher.async_dispatcher_send(self.signal_state_updated)
async def get_characteristics(self, *args, **kwargs):
"""Read latest state from homekit accessory."""
async with self.pairing_lock:
- chars = await self.hass.async_add_executor_job(
- self.pairing.get_characteristics, *args, **kwargs
- )
- return chars
+ return await self.pairing.get_characteristics(*args, **kwargs)
async def put_characteristics(self, characteristics):
"""Control a HomeKit device state from Home Assistant."""
- chars = []
- for row in characteristics:
- chars.append((row["aid"], row["iid"], row["value"]))
-
async with self.pairing_lock:
- results = await self.hass.async_add_executor_job(
- self.pairing.put_characteristics, chars
- )
+ results = await self.pairing.put_characteristics(characteristics)
# Feed characteristics back into HA and update the current state
# results will only contain failures, so anythin in characteristics
@@ -327,8 +347,8 @@ class HKDevice:
# reflect the change immediately.
new_entity_state = {}
- for row in characteristics:
- key = (row["aid"], row["iid"])
+ for aid, iid, value in characteristics:
+ key = (aid, iid)
# If the key was returned by put_characteristics() then the
# change didn't work
@@ -337,7 +357,7 @@ class HKDevice:
# Otherwise it was accepted and we can apply the change to
# our state
- new_entity_state[key] = {"value": row["value"]}
+ new_entity_state[key] = {"value": value}
self.process_new_events(new_entity_state)
diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py
index 684f83ba5d4..f5ae6cbd644 100644
--- a/homeassistant/components/homekit_controller/const.py
+++ b/homeassistant/components/homekit_controller/const.py
@@ -30,4 +30,6 @@ HOMEKIT_ACCESSORY_DISPATCH = {
"fan": "fan",
"fanv2": "fan",
"air-quality": "air_quality",
+ "occupancy": "binary_sensor",
+ "television": "media_player",
}
diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py
index 191405a9355..9b73846d6a7 100644
--- a/homeassistant/components/homekit_controller/cover.py
+++ b/homeassistant/components/homekit_controller/cover.py
@@ -1,7 +1,7 @@
"""Support for Homekit covers."""
import logging
-from homekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.cover import (
ATTR_POSITION,
@@ -61,13 +61,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice):
"""Representation of a HomeKit Garage Door."""
- def __init__(self, accessory, discovery_info):
- """Initialise the Cover."""
- super().__init__(accessory, discovery_info)
- self._state = None
- self._obstruction_detected = None
- self.lock_state = None
-
@property
def device_class(self):
"""Define this cover as a garage door."""
@@ -81,31 +74,31 @@ class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice):
CharacteristicsTypes.OBSTRUCTION_DETECTED,
]
- def _update_door_state_current(self, value):
- self._state = CURRENT_GARAGE_STATE_MAP[value]
-
- def _update_obstruction_detected(self, value):
- self._obstruction_detected = value
-
@property
def supported_features(self):
"""Flag supported features."""
return SUPPORT_OPEN | SUPPORT_CLOSE
+ @property
+ def state(self):
+ """Return the current state of the garage door."""
+ value = self.service.value(CharacteristicsTypes.DOOR_STATE_CURRENT)
+ return CURRENT_GARAGE_STATE_MAP[value]
+
@property
def is_closed(self):
"""Return true if cover is closed, else False."""
- return self._state == STATE_CLOSED
+ return self.state == STATE_CLOSED
@property
def is_closing(self):
"""Return if the cover is closing or not."""
- return self._state == STATE_CLOSING
+ return self.state == STATE_CLOSING
@property
def is_opening(self):
"""Return if the cover is opening or not."""
- return self._state == STATE_OPENING
+ return self.state == STATE_OPENING
async def async_open_cover(self, **kwargs):
"""Send open command."""
@@ -117,22 +110,22 @@ class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice):
async def set_door_state(self, state):
"""Send state command."""
- characteristics = [
- {
- "aid": self._aid,
- "iid": self._chars["door-state.target"],
- "value": TARGET_GARAGE_STATE_MAP[state],
- }
- ]
- await self._accessory.put_characteristics(characteristics)
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.DOOR_STATE_TARGET: TARGET_GARAGE_STATE_MAP[state]}
+ )
@property
def device_state_attributes(self):
"""Return the optional state attributes."""
- if self._obstruction_detected is None:
- return None
+ attributes = {}
- return {"obstruction-detected": self._obstruction_detected}
+ obstruction_detected = self.service.value(
+ CharacteristicsTypes.OBSTRUCTION_DETECTED
+ )
+ if obstruction_detected:
+ attributes["obstruction-detected"] = obstruction_detected
+
+ return attributes
class HomeKitWindowCover(HomeKitEntity, CoverDevice):
@@ -141,11 +134,7 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice):
def __init__(self, accessory, discovery_info):
"""Initialise the Cover."""
super().__init__(accessory, discovery_info)
- self._state = None
- self._position = None
- self._tilt_position = None
- self._obstruction_detected = None
- self.lock_state = None
+
self._features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION
def get_characteristic_types(self):
@@ -175,21 +164,6 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice):
SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_SET_TILT_POSITION
)
- def _update_position_state(self, value):
- self._state = CURRENT_WINDOW_STATE_MAP[value]
-
- def _update_position_current(self, value):
- self._position = value
-
- def _update_vertical_tilt_current(self, value):
- self._tilt_position = value
-
- def _update_horizontal_tilt_current(self, value):
- self._tilt_position = value
-
- def _update_obstruction_detected(self, value):
- self._obstruction_detected = value
-
@property
def supported_features(self):
"""Flag supported features."""
@@ -198,29 +172,54 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice):
@property
def current_cover_position(self):
"""Return the current position of cover."""
- return self._position
+ return self.service.value(CharacteristicsTypes.POSITION_CURRENT)
@property
def is_closed(self):
"""Return true if cover is closed, else False."""
- return self._position == 0
+ return self.current_cover_position == 0
@property
def is_closing(self):
"""Return if the cover is closing or not."""
- return self._state == STATE_CLOSING
+ value = self.service.value(CharacteristicsTypes.POSITION_STATE)
+ state = CURRENT_WINDOW_STATE_MAP[value]
+ return state == STATE_CLOSING
@property
def is_opening(self):
"""Return if the cover is opening or not."""
- return self._state == STATE_OPENING
+ value = self.service.value(CharacteristicsTypes.POSITION_STATE)
+ state = CURRENT_WINDOW_STATE_MAP[value]
+ return state == STATE_OPENING
+
+ @property
+ def is_horizontal_tilt(self):
+ """Return True if the service has a horizontal tilt characteristic."""
+ return (
+ self.service.value(CharacteristicsTypes.HORIZONTAL_TILT_CURRENT) is not None
+ )
+
+ @property
+ def is_vertical_tilt(self):
+ """Return True if the service has a vertical tilt characteristic."""
+ return (
+ self.service.value(CharacteristicsTypes.VERTICAL_TILT_CURRENT) is not None
+ )
+
+ @property
+ def current_cover_tilt_position(self):
+ """Return current position of cover tilt."""
+ tilt_position = self.service.value(CharacteristicsTypes.VERTICAL_TILT_CURRENT)
+ if not tilt_position:
+ tilt_position = self.service.value(
+ CharacteristicsTypes.HORIZONTAL_TILT_CURRENT
+ )
+ return tilt_position
async def async_stop_cover(self, **kwargs):
"""Send hold command."""
- characteristics = [
- {"aid": self._aid, "iid": self._chars["position.hold"], "value": 1}
- ]
- await self._accessory.put_characteristics(characteristics)
+ await self.async_put_characteristics({CharacteristicsTypes.POSITION_HOLD: 1})
async def async_open_cover(self, **kwargs):
"""Send open command."""
@@ -233,43 +232,31 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice):
async def async_set_cover_position(self, **kwargs):
"""Send position command."""
position = kwargs[ATTR_POSITION]
- characteristics = [
- {"aid": self._aid, "iid": self._chars["position.target"], "value": position}
- ]
- await self._accessory.put_characteristics(characteristics)
-
- @property
- def current_cover_tilt_position(self):
- """Return current position of cover tilt."""
- return self._tilt_position
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.POSITION_TARGET: position}
+ )
async def async_set_cover_tilt_position(self, **kwargs):
"""Move the cover tilt to a specific position."""
tilt_position = kwargs[ATTR_TILT_POSITION]
- if "vertical-tilt.target" in self._chars:
- characteristics = [
- {
- "aid": self._aid,
- "iid": self._chars["vertical-tilt.target"],
- "value": tilt_position,
- }
- ]
- await self._accessory.put_characteristics(characteristics)
- elif "horizontal-tilt.target" in self._chars:
- characteristics = [
- {
- "aid": self._aid,
- "iid": self._chars["horizontal-tilt.target"],
- "value": tilt_position,
- }
- ]
- await self._accessory.put_characteristics(characteristics)
+ if self.is_vertical_tilt:
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.VERTICAL_TILT_TARGET: tilt_position}
+ )
+ elif self.is_horizontal_tilt:
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.HORIZONTAL_TILT_TARGET: tilt_position}
+ )
@property
def device_state_attributes(self):
"""Return the optional state attributes."""
- state_attributes = {}
- if self._obstruction_detected is not None:
- state_attributes["obstruction-detected"] = self._obstruction_detected
+ attributes = {}
- return state_attributes
+ obstruction_detected = self.service.value(
+ CharacteristicsTypes.OBSTRUCTION_DETECTED
+ )
+ if obstruction_detected:
+ attributes["obstruction-detected"] = obstruction_detected
+
+ return attributes
diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py
index 694ae8a2c09..f0d6967684c 100644
--- a/homeassistant/components/homekit_controller/fan.py
+++ b/homeassistant/components/homekit_controller/fan.py
@@ -1,7 +1,7 @@
"""Support for Homekit fans."""
import logging
-from homekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.fan import (
DIRECTION_FORWARD,
@@ -46,12 +46,7 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
def __init__(self, *args):
"""Initialise the fan."""
- self._on = None
self._features = 0
- self._rotation_direction = 0
- self._rotation_speed = 0
- self._swing_mode = 0
-
super().__init__(*args)
def get_characteristic_types(self):
@@ -60,6 +55,7 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
CharacteristicsTypes.SWING_MODE,
CharacteristicsTypes.ROTATION_DIRECTION,
CharacteristicsTypes.ROTATION_SPEED,
+ self.on_characteristic,
]
def _setup_rotation_direction(self, char):
@@ -71,31 +67,28 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
def _setup_swing_mode(self, char):
self._features |= SUPPORT_OSCILLATE
- def _update_rotation_direction(self, value):
- self._rotation_direction = value
-
- def _update_rotation_speed(self, value):
- self._rotation_speed = value
-
- def _update_swing_mode(self, value):
- self._swing_mode = value
-
@property
def is_on(self):
"""Return true if device is on."""
- return self._on
+ return self.service.value(self.on_characteristic) == 1
@property
def speed(self):
"""Return the current speed."""
if not self.is_on:
return SPEED_OFF
- if self._rotation_speed > SPEED_TO_PCNT[SPEED_MEDIUM]:
+
+ rotation_speed = self.service.value(CharacteristicsTypes.ROTATION_SPEED)
+
+ if rotation_speed > SPEED_TO_PCNT[SPEED_MEDIUM]:
return SPEED_HIGH
- if self._rotation_speed > SPEED_TO_PCNT[SPEED_LOW]:
+
+ if rotation_speed > SPEED_TO_PCNT[SPEED_LOW]:
return SPEED_MEDIUM
- if self._rotation_speed > SPEED_TO_PCNT[SPEED_OFF]:
+
+ if rotation_speed > SPEED_TO_PCNT[SPEED_OFF]:
return SPEED_LOW
+
return SPEED_OFF
@property
@@ -108,12 +101,14 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
@property
def current_direction(self):
"""Return the current direction of the fan."""
- return HK_DIRECTION_TO_HA[self._rotation_direction]
+ direction = self.service.value(CharacteristicsTypes.ROTATION_DIRECTION)
+ return HK_DIRECTION_TO_HA[direction]
@property
def oscillating(self):
"""Return whether or not the fan is currently oscillating."""
- return self._swing_mode == 1
+ oscillating = self.service.value(CharacteristicsTypes.SWING_MODE)
+ return oscillating == 1
@property
def supported_features(self):
@@ -122,14 +117,8 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
async def async_set_direction(self, direction):
"""Set the direction of the fan."""
- await self._accessory.put_characteristics(
- [
- {
- "aid": self._aid,
- "iid": self._chars["rotation.direction"],
- "value": DIRECTION_TO_HK[direction],
- }
- ]
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.ROTATION_DIRECTION: DIRECTION_TO_HK[direction]}
)
async def async_set_speed(self, speed):
@@ -137,92 +126,45 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
if speed == SPEED_OFF:
return await self.async_turn_off()
- await self._accessory.put_characteristics(
- [
- {
- "aid": self._aid,
- "iid": self._chars["rotation.speed"],
- "value": SPEED_TO_PCNT[speed],
- }
- ]
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.ROTATION_SPEED: SPEED_TO_PCNT[speed]}
)
async def async_oscillate(self, oscillating: bool):
"""Oscillate the fan."""
- await self._accessory.put_characteristics(
- [
- {
- "aid": self._aid,
- "iid": self._chars["swing-mode"],
- "value": 1 if oscillating else 0,
- }
- ]
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.SWING_MODE: 1 if oscillating else 0}
)
async def async_turn_on(self, speed=None, **kwargs):
"""Turn the specified fan on."""
- characteristics = []
+ characteristics = {}
if not self.is_on:
- characteristics.append(
- {
- "aid": self._aid,
- "iid": self._chars[self.on_characteristic],
- "value": True,
- }
- )
+ characteristics[self.on_characteristic] = True
if self.supported_features & SUPPORT_SET_SPEED and speed:
- characteristics.append(
- {
- "aid": self._aid,
- "iid": self._chars["rotation.speed"],
- "value": SPEED_TO_PCNT[speed],
- },
- )
+ characteristics[CharacteristicsTypes.ROTATION_SPEED] = SPEED_TO_PCNT[speed]
- if not characteristics:
- return
-
- await self._accessory.put_characteristics(characteristics)
+ if characteristics:
+ await self.async_put_characteristics(characteristics)
async def async_turn_off(self, **kwargs):
"""Turn the specified fan off."""
- characteristics = [
- {
- "aid": self._aid,
- "iid": self._chars[self.on_characteristic],
- "value": False,
- }
- ]
- await self._accessory.put_characteristics(characteristics)
+ await self.async_put_characteristics({self.on_characteristic: False})
class HomeKitFanV1(BaseHomeKitFan):
"""Implement fan support for public.hap.service.fan."""
- on_characteristic = "on"
-
- def get_characteristic_types(self):
- """Define the homekit characteristics the entity cares about."""
- return [CharacteristicsTypes.ON] + super().get_characteristic_types()
-
- def _update_on(self, value):
- self._on = value == 1
+ on_characteristic = CharacteristicsTypes.ON
class HomeKitFanV2(BaseHomeKitFan):
"""Implement fan support for public.hap.service.fanv2."""
- on_characteristic = "active"
-
- def get_characteristic_types(self):
- """Define the homekit characteristics the entity cares about."""
- return [CharacteristicsTypes.ACTIVE] + super().get_characteristic_types()
-
- def _update_active(self, value):
- self._on = value == 1
+ on_characteristic = CharacteristicsTypes.ACTIVE
ENTITY_TYPES = {
diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py
index e7d1e4d3273..14ed74cc085 100644
--- a/homeassistant/components/homekit_controller/light.py
+++ b/homeassistant/components/homekit_controller/light.py
@@ -1,7 +1,7 @@
"""Support for Homekit lights."""
import logging
-from homekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -38,15 +38,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
class HomeKitLight(HomeKitEntity, Light):
"""Representation of a Homekit light."""
- def __init__(self, *args):
- """Initialise the light."""
- super().__init__(*args)
- self._on = False
- self._brightness = 0
- self._color_temperature = 0
- self._hue = 0
- self._saturation = 0
-
def get_characteristic_types(self):
"""Define the homekit characteristics the entity cares about."""
return [
@@ -69,40 +60,28 @@ class HomeKitLight(HomeKitEntity, Light):
def _setup_saturation(self, char):
self._features |= SUPPORT_COLOR
- def _update_on(self, value):
- self._on = value
-
- def _update_brightness(self, value):
- self._brightness = value
-
- def _update_color_temperature(self, value):
- self._color_temperature = value
-
- def _update_hue(self, value):
- self._hue = value
-
- def _update_saturation(self, value):
- self._saturation = value
-
@property
def is_on(self):
"""Return true if device is on."""
- return self._on
+ return self.service.value(CharacteristicsTypes.ON)
@property
def brightness(self):
"""Return the brightness of this light between 0..255."""
- return self._brightness * 255 / 100
+ return self.service.value(CharacteristicsTypes.BRIGHTNESS) * 255 / 100
@property
def hs_color(self):
"""Return the color property."""
- return (self._hue, self._saturation)
+ return (
+ self.service.value(CharacteristicsTypes.HUE),
+ self.service.value(CharacteristicsTypes.SATURATION),
+ )
@property
def color_temp(self):
"""Return the color temperature."""
- return self._color_temperature
+ return self.service.value(CharacteristicsTypes.COLOR_TEMPERATURE)
@property
def supported_features(self):
@@ -115,41 +94,28 @@ class HomeKitLight(HomeKitEntity, Light):
temperature = kwargs.get(ATTR_COLOR_TEMP)
brightness = kwargs.get(ATTR_BRIGHTNESS)
- characteristics = []
+ characteristics = {}
+
if hs_color is not None:
- characteristics.append(
- {"aid": self._aid, "iid": self._chars["hue"], "value": hs_color[0]}
- )
- characteristics.append(
+ characteristics.update(
{
- "aid": self._aid,
- "iid": self._chars["saturation"],
- "value": hs_color[1],
+ CharacteristicsTypes.HUE: hs_color[0],
+ CharacteristicsTypes.SATURATION: hs_color[1],
}
)
+
if brightness is not None:
- characteristics.append(
- {
- "aid": self._aid,
- "iid": self._chars["brightness"],
- "value": int(brightness * 100 / 255),
- }
+ characteristics[CharacteristicsTypes.BRIGHTNESS] = int(
+ brightness * 100 / 255
)
if temperature is not None:
- characteristics.append(
- {
- "aid": self._aid,
- "iid": self._chars["color-temperature"],
- "value": int(temperature),
- }
- )
- characteristics.append(
- {"aid": self._aid, "iid": self._chars["on"], "value": True}
- )
- await self._accessory.put_characteristics(characteristics)
+ characteristics[CharacteristicsTypes.COLOR_TEMPERATURE] = int(temperature)
+
+ characteristics[CharacteristicsTypes.ON] = True
+
+ await self.async_put_characteristics(characteristics)
async def async_turn_off(self, **kwargs):
"""Turn the specified light off."""
- characteristics = [{"aid": self._aid, "iid": self._chars["on"], "value": False}]
- await self._accessory.put_characteristics(characteristics)
+ await self.async_put_characteristics({CharacteristicsTypes.ON: False})
diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py
index 1799d30d8c8..c07f85fb50f 100644
--- a/homeassistant/components/homekit_controller/lock.py
+++ b/homeassistant/components/homekit_controller/lock.py
@@ -1,7 +1,7 @@
"""Support for HomeKit Controller locks."""
import logging
-from homekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.lock import LockDevice
from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_LOCKED, STATE_UNLOCKED
@@ -37,12 +37,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
class HomeKitLock(HomeKitEntity, LockDevice):
"""Representation of a HomeKit Controller Lock."""
- def __init__(self, accessory, discovery_info):
- """Initialise the Lock."""
- super().__init__(accessory, discovery_info)
- self._state = None
- self._battery_level = None
-
def get_characteristic_types(self):
"""Define the homekit characteristics the entity cares about."""
return [
@@ -51,16 +45,11 @@ class HomeKitLock(HomeKitEntity, LockDevice):
CharacteristicsTypes.BATTERY_LEVEL,
]
- def _update_lock_mechanism_current_state(self, value):
- self._state = CURRENT_STATE_MAP[value]
-
- def _update_battery_level(self, value):
- self._battery_level = value
-
@property
def is_locked(self):
"""Return true if device is locked."""
- return self._state == STATE_LOCKED
+ value = self.service.value(CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE)
+ return CURRENT_STATE_MAP[value] == STATE_LOCKED
async def async_lock(self, **kwargs):
"""Lock the device."""
@@ -72,19 +61,17 @@ class HomeKitLock(HomeKitEntity, LockDevice):
async def _set_lock_state(self, state):
"""Send state command."""
- characteristics = [
- {
- "aid": self._aid,
- "iid": self._chars["lock-mechanism.target-state"],
- "value": TARGET_STATE_MAP[state],
- }
- ]
- await self._accessory.put_characteristics(characteristics)
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE: TARGET_STATE_MAP[state]}
+ )
@property
def device_state_attributes(self):
"""Return the optional state attributes."""
- if self._battery_level is None:
- return None
+ attributes = {}
- return {ATTR_BATTERY_LEVEL: self._battery_level}
+ battery_level = self.service.value(CharacteristicsTypes.BATTERY_LEVEL)
+ if battery_level:
+ attributes[ATTR_BATTERY_LEVEL] = battery_level
+
+ return attributes
diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json
index c7eb02a479c..a73d68227c7 100644
--- a/homeassistant/components/homekit_controller/manifest.json
+++ b/homeassistant/components/homekit_controller/manifest.json
@@ -3,7 +3,7 @@
"name": "HomeKit Controller",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
- "requirements": ["homekit[IP]==0.15.0"],
+ "requirements": ["aiohomekit[IP]==0.2.29.1"],
"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
new file mode 100644
index 00000000000..3a1a7359e13
--- /dev/null
+++ b/homeassistant/components/homekit_controller/media_player.py
@@ -0,0 +1,220 @@
+"""Support for HomeKit Controller Televisions."""
+import logging
+
+from aiohomekit.model.characteristics import (
+ CharacteristicsTypes,
+ CurrentMediaStateValues,
+ RemoteKeyValues,
+ TargetMediaStateValues,
+)
+from aiohomekit.model.services import ServicesTypes
+from aiohomekit.utils import clamp_enum_to_char
+
+from homeassistant.components.media_player import DEVICE_CLASS_TV, MediaPlayerDevice
+from homeassistant.components.media_player.const import (
+ SUPPORT_PAUSE,
+ SUPPORT_PLAY,
+ SUPPORT_SELECT_SOURCE,
+ SUPPORT_STOP,
+)
+from homeassistant.const import (
+ STATE_IDLE,
+ STATE_OK,
+ STATE_PAUSED,
+ STATE_PLAYING,
+ STATE_PROBLEM,
+)
+from homeassistant.core import callback
+
+from . import KNOWN_DEVICES, HomeKitEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+HK_TO_HA_STATE = {
+ CurrentMediaStateValues.PLAYING: STATE_PLAYING,
+ CurrentMediaStateValues.PAUSED: STATE_PAUSED,
+ CurrentMediaStateValues.STOPPED: STATE_IDLE,
+}
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Homekit television."""
+ hkid = config_entry.data["AccessoryPairingID"]
+ conn = hass.data[KNOWN_DEVICES][hkid]
+
+ @callback
+ def async_add_service(aid, service):
+ if service["stype"] != "television":
+ return False
+ info = {"aid": aid, "iid": service["iid"]}
+ async_add_entities([HomeKitTelevision(conn, info)], True)
+ return True
+
+ conn.add_listener(async_add_service)
+
+
+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 [
+ CharacteristicsTypes.ACTIVE,
+ CharacteristicsTypes.CURRENT_MEDIA_STATE,
+ CharacteristicsTypes.TARGET_MEDIA_STATE,
+ CharacteristicsTypes.REMOTE_KEY,
+ CharacteristicsTypes.ACTIVE_IDENTIFIER,
+ # Characterics that are on the linked INPUT_SOURCE services
+ CharacteristicsTypes.CONFIGURED_NAME,
+ 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."""
+ return DEVICE_CLASS_TV
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return self._features
+
+ @property
+ def source_list(self):
+ """List of all input sources for this television."""
+ sources = []
+
+ this_accessory = self._accessory.entity_map.aid(self._aid)
+ this_tv = this_accessory.services.iid(self._iid)
+
+ input_sources = this_accessory.services.filter(
+ service_type=ServicesTypes.INPUT_SOURCE, parent_service=this_tv,
+ )
+
+ for input_source in input_sources:
+ char = input_source[CharacteristicsTypes.CONFIGURED_NAME]
+ sources.append(char.value)
+ return sources
+
+ @property
+ def source(self):
+ """Name of the current input source."""
+ active_identifier = self.service.value(CharacteristicsTypes.ACTIVE_IDENTIFIER)
+ if not active_identifier:
+ return None
+
+ this_accessory = self._accessory.entity_map.aid(self._aid)
+ this_tv = this_accessory.services.iid(self._iid)
+
+ input_source = this_accessory.services.first(
+ service_type=ServicesTypes.INPUT_SOURCE,
+ characteristics={CharacteristicsTypes.IDENTIFIER: active_identifier},
+ parent_service=this_tv,
+ )
+ char = input_source[CharacteristicsTypes.CONFIGURED_NAME]
+ return char.value
+
+ @property
+ def state(self):
+ """State of the tv."""
+ active = self.service.value(CharacteristicsTypes.ACTIVE)
+ if not active:
+ return STATE_PROBLEM
+
+ homekit_state = self.service.value(CharacteristicsTypes.CURRENT_MEDIA_STATE)
+ if homekit_state is not None:
+ return HK_TO_HA_STATE.get(homekit_state, STATE_OK)
+
+ return STATE_OK
+
+ async def async_media_play(self):
+ """Send play command."""
+ if self.state == STATE_PLAYING:
+ _LOGGER.debug("Cannot play while already playing")
+ return
+
+ if TargetMediaStateValues.PLAY in self._supported_target_media_state:
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.TARGET_MEDIA_STATE: TargetMediaStateValues.PLAY}
+ )
+ elif RemoteKeyValues.PLAY_PAUSE in self._supported_remote_key:
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.REMOTE_KEY: RemoteKeyValues.PLAY_PAUSE}
+ )
+
+ async def async_media_pause(self):
+ """Send pause command."""
+ if self.state == STATE_PAUSED:
+ _LOGGER.debug("Cannot pause while already paused")
+ return
+
+ if TargetMediaStateValues.PAUSE in self._supported_target_media_state:
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.TARGET_MEDIA_STATE: TargetMediaStateValues.PAUSE}
+ )
+ elif RemoteKeyValues.PLAY_PAUSE in self._supported_remote_key:
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.REMOTE_KEY: RemoteKeyValues.PLAY_PAUSE}
+ )
+
+ async def async_media_stop(self):
+ """Send stop command."""
+ if self.state == STATE_IDLE:
+ _LOGGER.debug("Cannot stop when already idle")
+ return
+
+ if TargetMediaStateValues.STOP in self._supported_target_media_state:
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.TARGET_MEDIA_STATE: TargetMediaStateValues.STOP}
+ )
+
+ async def async_select_source(self, source):
+ """Switch to a different media source."""
+ this_accessory = self._accessory.entity_map.aid(self._aid)
+ this_tv = this_accessory.services.iid(self._iid)
+
+ input_source = this_accessory.services.first(
+ service_type=ServicesTypes.INPUT_SOURCE,
+ characteristics={CharacteristicsTypes.CONFIGURED_NAME: source},
+ parent_service=this_tv,
+ )
+
+ if not input_source:
+ raise ValueError(f"Could not find source {source}")
+
+ identifier = input_source[CharacteristicsTypes.IDENTIFIER]
+
+ await self.async_put_characteristics(
+ {CharacteristicsTypes.ACTIVE_IDENTIFIER: identifier.value}
+ )
diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py
index e59dda007d4..87f47e72023 100644
--- a/homeassistant/components/homekit_controller/sensor.py
+++ b/homeassistant/components/homekit_controller/sensor.py
@@ -1,7 +1,15 @@
"""Support for Homekit sensors."""
-from homekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.characteristics import CharacteristicsTypes
-from homeassistant.const import DEVICE_CLASS_BATTERY, TEMP_CELSIUS
+from homeassistant.const import (
+ CONCENTRATION_PARTS_PER_MILLION,
+ DEVICE_CLASS_BATTERY,
+ DEVICE_CLASS_HUMIDITY,
+ DEVICE_CLASS_ILLUMINANCE,
+ DEVICE_CLASS_TEMPERATURE,
+ TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
+)
from homeassistant.core import callback
from . import KNOWN_DEVICES, HomeKitEntity
@@ -11,23 +19,21 @@ TEMP_C_ICON = "mdi:thermometer"
BRIGHTNESS_ICON = "mdi:brightness-6"
CO2_ICON = "mdi:periodic-table-co2"
-UNIT_PERCENT = "%"
UNIT_LUX = "lux"
-UNIT_CO2 = "ppm"
class HomeKitHumiditySensor(HomeKitEntity):
"""Representation of a Homekit humidity sensor."""
- def __init__(self, *args):
- """Initialise the entity."""
- super().__init__(*args)
- self._state = None
-
def get_characteristic_types(self):
"""Define the homekit characteristics the entity is tracking."""
return [CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT]
+ @property
+ def device_class(self) -> str:
+ """Return the device class of the sensor."""
+ return DEVICE_CLASS_HUMIDITY
+
@property
def name(self):
"""Return the name of the device."""
@@ -41,29 +47,26 @@ class HomeKitHumiditySensor(HomeKitEntity):
@property
def unit_of_measurement(self):
"""Return units for the sensor."""
- return UNIT_PERCENT
-
- def _update_relative_humidity_current(self, value):
- self._state = value
+ return UNIT_PERCENTAGE
@property
def state(self):
"""Return the current humidity."""
- return self._state
+ return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT)
class HomeKitTemperatureSensor(HomeKitEntity):
"""Representation of a Homekit temperature sensor."""
- def __init__(self, *args):
- """Initialise the entity."""
- super().__init__(*args)
- self._state = None
-
def get_characteristic_types(self):
"""Define the homekit characteristics the entity is tracking."""
return [CharacteristicsTypes.TEMPERATURE_CURRENT]
+ @property
+ def device_class(self) -> str:
+ """Return the device class of the sensor."""
+ return DEVICE_CLASS_TEMPERATURE
+
@property
def name(self):
"""Return the name of the device."""
@@ -79,27 +82,24 @@ class HomeKitTemperatureSensor(HomeKitEntity):
"""Return units for the sensor."""
return TEMP_CELSIUS
- def _update_temperature_current(self, value):
- self._state = value
-
@property
def state(self):
"""Return the current temperature in Celsius."""
- return self._state
+ return self.service.value(CharacteristicsTypes.TEMPERATURE_CURRENT)
class HomeKitLightSensor(HomeKitEntity):
"""Representation of a Homekit light level sensor."""
- def __init__(self, *args):
- """Initialise the entity."""
- super().__init__(*args)
- self._state = None
-
def get_characteristic_types(self):
"""Define the homekit characteristics the entity is tracking."""
return [CharacteristicsTypes.LIGHT_LEVEL_CURRENT]
+ @property
+ def device_class(self) -> str:
+ """Return the device class of the sensor."""
+ return DEVICE_CLASS_ILLUMINANCE
+
@property
def name(self):
"""Return the name of the device."""
@@ -115,23 +115,15 @@ class HomeKitLightSensor(HomeKitEntity):
"""Return units for the sensor."""
return UNIT_LUX
- def _update_light_level_current(self, value):
- self._state = value
-
@property
def state(self):
"""Return the current light level in lux."""
- return self._state
+ return self.service.value(CharacteristicsTypes.LIGHT_LEVEL_CURRENT)
class HomeKitCarbonDioxideSensor(HomeKitEntity):
"""Representation of a Homekit Carbon Dioxide sensor."""
- def __init__(self, *args):
- """Initialise the entity."""
- super().__init__(*args)
- self._state = None
-
def get_characteristic_types(self):
"""Define the homekit characteristics the entity is tracking."""
return [CharacteristicsTypes.CARBON_DIOXIDE_LEVEL]
@@ -149,27 +141,17 @@ class HomeKitCarbonDioxideSensor(HomeKitEntity):
@property
def unit_of_measurement(self):
"""Return units for the sensor."""
- return UNIT_CO2
-
- def _update_carbon_dioxide_level(self, value):
- self._state = value
+ return CONCENTRATION_PARTS_PER_MILLION
@property
def state(self):
"""Return the current CO2 level in ppm."""
- return self._state
+ return self.service.value(CharacteristicsTypes.CARBON_DIOXIDE_LEVEL)
class HomeKitBatterySensor(HomeKitEntity):
"""Representation of a Homekit battery sensor."""
- def __init__(self, *args):
- """Initialise the entity."""
- super().__init__(*args)
- self._state = None
- self._low_battery = False
- self._charging = False
-
def get_characteristic_types(self):
"""Define the homekit characteristics the entity is tracking."""
return [
@@ -197,12 +179,12 @@ class HomeKitBatterySensor(HomeKitEntity):
# This is similar to the logic in helpers.icon, but we have delegated the
# decision about what mdi:battery-alert is to the device.
icon = "mdi:battery"
- if self._charging and self.state > 10:
+ if self.is_charging and self.state > 10:
percentage = int(round(self.state / 20 - 0.01)) * 20
icon += f"-charging-{percentage}"
- elif self._charging:
+ elif self.is_charging:
icon += "-outline"
- elif self._low_battery:
+ elif self.is_low_battery:
icon += "-alert"
elif self.state < 95:
percentage = max(int(round(self.state / 10 - 0.01)) * 10, 10)
@@ -213,24 +195,25 @@ class HomeKitBatterySensor(HomeKitEntity):
@property
def unit_of_measurement(self):
"""Return units for the sensor."""
- return UNIT_PERCENT
+ return UNIT_PERCENTAGE
- def _update_battery_level(self, value):
- self._state = value
+ @property
+ def is_low_battery(self):
+ """Return true if battery level is low."""
+ return self.service.value(CharacteristicsTypes.STATUS_LO_BATT) == 1
- def _update_status_lo_batt(self, value):
- self._low_battery = value == 1
-
- def _update_charging_state(self, value):
+ @property
+ def is_charging(self):
+ """Return true if currently charing."""
# 0 = not charging
# 1 = charging
# 2 = not chargeable
- self._charging = value == 1
+ return self.service.value(CharacteristicsTypes.CHARGING_STATE) == 1
@property
def state(self):
"""Return the current battery level percentage."""
- return self._state
+ return self.service.value(CharacteristicsTypes.BATTERY_LEVEL)
ENTITY_TYPES = {
diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json
index 55718e35b59..80370717183 100644
--- a/homeassistant/components/homekit_controller/strings.json
+++ b/homeassistant/components/homekit_controller/strings.json
@@ -25,7 +25,7 @@
"max_peers_error": "Device refused to add pairing as it has no free pairing storage.",
"busy_error": "Device refused to add pairing as it is already pairing with another controller.",
"max_tries_error": "Device refused to add pairing as it has received more than 100 unsuccessful authentication attempts.",
- "pairing_failed": "An unhandled error occured while attempting to pair with this device. This may be a temporary failure or your device may not be supported currently."
+ "pairing_failed": "An unhandled error occurred while attempting to pair with this device. This may be a temporary failure or your device may not be supported currently."
},
"abort": {
"no_devices": "No unpaired devices could be found",
diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py
index 60b16c8ddab..5897bbb7b3f 100644
--- a/homeassistant/components/homekit_controller/switch.py
+++ b/homeassistant/components/homekit_controller/switch.py
@@ -1,7 +1,7 @@
"""Support for Homekit switches."""
import logging
-from homekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.characteristics import CharacteristicsTypes
from homeassistant.components.switch import SwitchDevice
from homeassistant.core import callback
@@ -32,40 +32,26 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
class HomeKitSwitch(HomeKitEntity, SwitchDevice):
"""Representation of a Homekit switch."""
- def __init__(self, *args):
- """Initialise the switch."""
- super().__init__(*args)
- self._on = None
- self._outlet_in_use = None
-
def get_characteristic_types(self):
"""Define the homekit characteristics the entity cares about."""
return [CharacteristicsTypes.ON, CharacteristicsTypes.OUTLET_IN_USE]
- def _update_on(self, value):
- self._on = value
-
- def _update_outlet_in_use(self, value):
- self._outlet_in_use = value
-
@property
def is_on(self):
"""Return true if device is on."""
- return self._on
+ return self.service.value(CharacteristicsTypes.ON)
async def async_turn_on(self, **kwargs):
"""Turn the specified switch on."""
- self._on = True
- characteristics = [{"aid": self._aid, "iid": self._chars["on"], "value": True}]
- await self._accessory.put_characteristics(characteristics)
+ await self.async_put_characteristics({CharacteristicsTypes.ON: True})
async def async_turn_off(self, **kwargs):
"""Turn the specified switch off."""
- characteristics = [{"aid": self._aid, "iid": self._chars["on"], "value": False}]
- await self._accessory.put_characteristics(characteristics)
+ await self.async_put_characteristics({CharacteristicsTypes.ON: False})
@property
def device_state_attributes(self):
"""Return the optional state attributes."""
- if self._outlet_in_use is not None:
- return {OUTLET_IN_USE: self._outlet_in_use}
+ outlet_in_use = self.service.value(CharacteristicsTypes.OUTLET_IN_USE)
+ if outlet_in_use is not None:
+ return {OUTLET_IN_USE: outlet_in_use}
diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py
index 54811c3ccdf..49d3ee1f170 100644
--- a/homeassistant/components/homematic/entity.py
+++ b/homeassistant/components/homematic/entity.py
@@ -200,7 +200,7 @@ class HMHub(Entity):
def __init__(self, hass, homematic, name):
"""Initialize HomeMatic hub."""
self.hass = hass
- self.entity_id = "{}.{}".format(DOMAIN, name.lower())
+ self.entity_id = f"{DOMAIN}.{name.lower()}"
self._homematic = homematic
self._variables = {}
self._name = name
diff --git a/homeassistant/components/homematic/manifest.json b/homeassistant/components/homematic/manifest.json
index edf07c3e4d7..20ea0d6acb1 100644
--- a/homeassistant/components/homematic/manifest.json
+++ b/homeassistant/components/homematic/manifest.json
@@ -2,7 +2,7 @@
"domain": "homematic",
"name": "Homematic",
"documentation": "https://www.home-assistant.io/integrations/homematic",
- "requirements": ["pyhomematic==0.1.64"],
+ "requirements": ["pyhomematic==0.1.65"],
"dependencies": [],
"codeowners": ["@pvizeli", "@danielperna84"]
}
diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py
index bba8325650d..e8b97477546 100644
--- a/homeassistant/components/homematic/sensor.py
+++ b/homeassistant/components/homematic/sensor.py
@@ -8,6 +8,9 @@ from homeassistant.const import (
DEVICE_CLASS_TEMPERATURE,
ENERGY_WATT_HOUR,
POWER_WATT,
+ SPEED_KILOMETERS_PER_HOUR,
+ UNIT_PERCENTAGE,
+ VOLUME_CUBIC_METERS,
)
from .const import ATTR_DISCOVER_DEVICES
@@ -30,7 +33,7 @@ HM_STATE_HA_CAST = {
}
HM_UNIT_HA_CAST = {
- "HUMIDITY": "%",
+ "HUMIDITY": UNIT_PERCENTAGE,
"TEMPERATURE": "°C",
"ACTUAL_TEMPERATURE": "°C",
"BRIGHTNESS": "#",
@@ -38,8 +41,8 @@ HM_UNIT_HA_CAST = {
"CURRENT": "mA",
"VOLTAGE": "V",
"ENERGY_COUNTER": ENERGY_WATT_HOUR,
- "GAS_POWER": "m3",
- "GAS_ENERGY_COUNTER": "m3",
+ "GAS_POWER": VOLUME_CUBIC_METERS,
+ "GAS_ENERGY_COUNTER": VOLUME_CUBIC_METERS,
"LUX": "lx",
"ILLUMINATION": "lx",
"CURRENT_ILLUMINATION": "lx",
@@ -47,7 +50,7 @@ HM_UNIT_HA_CAST = {
"LOWEST_ILLUMINATION": "lx",
"HIGHEST_ILLUMINATION": "lx",
"RAIN_COUNTER": "mm",
- "WIND_SPEED": "km/h",
+ "WIND_SPEED": SPEED_KILOMETERS_PER_HOUR,
"WIND_DIRECTION": "°",
"WIND_DIRECTION_RANGE": "°",
"SUNSHINEDURATION": "#",
diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py
index 0d6fc726050..dd85827f1ae 100644
--- a/homeassistant/components/homematicip_cloud/hap.py
+++ b/homeassistant/components/homematicip_cloud/hap.py
@@ -137,11 +137,6 @@ class HomematicipHAP:
job = self.hass.async_create_task(self.get_state())
job.add_done_callback(self.get_state_finished)
self._accesspoint_connected = True
- else:
- # Update home with the given json from arg[0],
- # without devices and groups.
-
- self.home.update_home_only(args[0])
@callback
def async_create_entity(self, *args, **kwargs) -> None:
diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py
index 4e081f4d8fa..cead186db95 100644
--- a/homeassistant/components/homematicip_cloud/light.py
+++ b/homeassistant/components/homematicip_cloud/light.py
@@ -20,6 +20,7 @@ from homeassistant.components.light import (
ATTR_TRANSITION,
SUPPORT_BRIGHTNESS,
SUPPORT_COLOR,
+ SUPPORT_TRANSITION,
Light,
)
from homeassistant.config_entries import ConfigEntry
@@ -197,7 +198,7 @@ class HomematicipNotificationLight(HomematicipGenericDevice, Light):
@property
def supported_features(self) -> int:
"""Flag supported features."""
- return SUPPORT_BRIGHTNESS | SUPPORT_COLOR
+ return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_TRANSITION
@property
def unique_id(self) -> str:
diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py
index d6a226a83dc..a45591ecc30 100644
--- a/homeassistant/components/homematicip_cloud/sensor.py
+++ b/homeassistant/components/homematicip_cloud/sensor.py
@@ -31,7 +31,9 @@ from homeassistant.const import (
DEVICE_CLASS_POWER,
DEVICE_CLASS_TEMPERATURE,
POWER_WATT,
+ SPEED_KILOMETERS_PER_HOUR,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers.typing import HomeAssistantType
@@ -154,7 +156,7 @@ class HomematicipAccesspointStatus(HomematicipGenericDevice):
@property
def unit_of_measurement(self) -> str:
"""Return the unit this state is expressed in."""
- return "%"
+ return UNIT_PERCENTAGE
@property
def device_state_attributes(self) -> Dict[str, Any]:
@@ -193,7 +195,7 @@ class HomematicipHeatingThermostat(HomematicipGenericDevice):
@property
def unit_of_measurement(self) -> str:
"""Return the unit this state is expressed in."""
- return "%"
+ return UNIT_PERCENTAGE
class HomematicipHumiditySensor(HomematicipGenericDevice):
@@ -216,7 +218,7 @@ class HomematicipHumiditySensor(HomematicipGenericDevice):
@property
def unit_of_measurement(self) -> str:
"""Return the unit this state is expressed in."""
- return "%"
+ return UNIT_PERCENTAGE
class HomematicipTemperatureSensor(HomematicipGenericDevice):
@@ -332,7 +334,7 @@ class HomematicipWindspeedSensor(HomematicipGenericDevice):
@property
def unit_of_measurement(self) -> str:
"""Return the unit this state is expressed in."""
- return "km/h"
+ return SPEED_KILOMETERS_PER_HOUR
@property
def device_state_attributes(self) -> Dict[str, Any]:
diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py
index 193cac94629..d8535edda50 100644
--- a/homeassistant/components/homematicip_cloud/services.py
+++ b/homeassistant/components/homematicip_cloud/services.py
@@ -12,6 +12,10 @@ import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import comp_entity_ids
+from homeassistant.helpers.service import (
+ async_register_admin_service,
+ verify_domain_control,
+)
from homeassistant.helpers.typing import HomeAssistantType, ServiceCallType
from .const import DOMAIN as HMIPC_DOMAIN
@@ -38,17 +42,6 @@ SERVICE_DUMP_HAP_CONFIG = "dump_hap_config"
SERVICE_RESET_ENERGY_COUNTER = "reset_energy_counter"
SERVICE_SET_ACTIVE_CLIMATE_PROFILE = "set_active_climate_profile"
-HMIPC_SERVICES2 = {
- SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION: "_async_activate_eco_mode_with_duration",
- SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD: "_async_activate_eco_mode_with_period",
- SERVICE_ACTIVATE_VACATION: "_async_activate_vacation",
- SERVICE_DEACTIVATE_ECO_MODE: "SERVICE_DEACTIVATE_ECO_MODE",
- SERVICE_DEACTIVATE_VACATION: "_async_deactivate_vacation",
- SERVICE_DUMP_HAP_CONFIG: "_async_dump_hap_config",
- SERVICE_RESET_ENERGY_COUNTER: "_async_reset_energy_counter",
- SERVICE_SET_ACTIVE_CLIMATE_PROFILE: "_set_active_climate_profile",
-}
-
HMIPC_SERVICES = [
SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION,
SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD,
@@ -120,6 +113,7 @@ async def async_setup_services(hass: HomeAssistantType) -> None:
if hass.services.async_services().get(HMIPC_DOMAIN):
return
+ @verify_domain_control(hass, HMIPC_DOMAIN)
async def async_call_hmipc_service(service: ServiceCallType):
"""Call correct HomematicIP Cloud service."""
service_name = service.service
@@ -142,58 +136,60 @@ async def async_setup_services(hass: HomeAssistantType) -> None:
await _set_active_climate_profile(hass, service)
hass.services.async_register(
- HMIPC_DOMAIN,
- SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION,
- async_call_hmipc_service,
+ domain=HMIPC_DOMAIN,
+ service=SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION,
+ service_func=async_call_hmipc_service,
schema=SCHEMA_ACTIVATE_ECO_MODE_WITH_DURATION,
)
hass.services.async_register(
- HMIPC_DOMAIN,
- SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD,
- async_call_hmipc_service,
+ domain=HMIPC_DOMAIN,
+ service=SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD,
+ service_func=async_call_hmipc_service,
schema=SCHEMA_ACTIVATE_ECO_MODE_WITH_PERIOD,
)
hass.services.async_register(
- HMIPC_DOMAIN,
- SERVICE_ACTIVATE_VACATION,
- async_call_hmipc_service,
+ domain=HMIPC_DOMAIN,
+ service=SERVICE_ACTIVATE_VACATION,
+ service_func=async_call_hmipc_service,
schema=SCHEMA_ACTIVATE_VACATION,
)
hass.services.async_register(
- HMIPC_DOMAIN,
- SERVICE_DEACTIVATE_ECO_MODE,
- async_call_hmipc_service,
+ domain=HMIPC_DOMAIN,
+ service=SERVICE_DEACTIVATE_ECO_MODE,
+ service_func=async_call_hmipc_service,
schema=SCHEMA_DEACTIVATE_ECO_MODE,
)
hass.services.async_register(
- HMIPC_DOMAIN,
- SERVICE_DEACTIVATE_VACATION,
- async_call_hmipc_service,
+ domain=HMIPC_DOMAIN,
+ service=SERVICE_DEACTIVATE_VACATION,
+ service_func=async_call_hmipc_service,
schema=SCHEMA_DEACTIVATE_VACATION,
)
hass.services.async_register(
- HMIPC_DOMAIN,
- SERVICE_SET_ACTIVE_CLIMATE_PROFILE,
- async_call_hmipc_service,
+ domain=HMIPC_DOMAIN,
+ service=SERVICE_SET_ACTIVE_CLIMATE_PROFILE,
+ service_func=async_call_hmipc_service,
schema=SCHEMA_SET_ACTIVE_CLIMATE_PROFILE,
)
- hass.services.async_register(
- HMIPC_DOMAIN,
- SERVICE_DUMP_HAP_CONFIG,
- async_call_hmipc_service,
+ async_register_admin_service(
+ hass=hass,
+ domain=HMIPC_DOMAIN,
+ service=SERVICE_DUMP_HAP_CONFIG,
+ service_func=async_call_hmipc_service,
schema=SCHEMA_DUMP_HAP_CONFIG,
)
- hass.helpers.service.async_register_admin_service(
- HMIPC_DOMAIN,
- SERVICE_RESET_ENERGY_COUNTER,
- async_call_hmipc_service,
+ async_register_admin_service(
+ hass=hass,
+ domain=HMIPC_DOMAIN,
+ service=SERVICE_RESET_ENERGY_COUNTER,
+ service_func=async_call_hmipc_service,
schema=SCHEMA_RESET_ENERGY_COUNTER,
)
@@ -204,7 +200,7 @@ async def async_unload_services(hass: HomeAssistantType):
return
for hmipc_service in HMIPC_SERVICES:
- hass.services.async_remove(HMIPC_DOMAIN, hmipc_service)
+ hass.services.async_remove(domain=HMIPC_DOMAIN, service=hmipc_service)
async def _async_activate_eco_mode_with_duration(
diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py
index c6296d8f4c6..7ae3d30de90 100644
--- a/homeassistant/components/homeworks/__init__.py
+++ b/homeassistant/components/homeworks/__init__.py
@@ -22,7 +22,6 @@ _LOGGER = logging.getLogger(__name__)
DOMAIN = "homeworks"
HOMEWORKS_CONTROLLER = "homeworks"
-ENTITY_SIGNAL = "homeworks_entity_{}"
EVENT_BUTTON_PRESS = "homeworks_button_press"
EVENT_BUTTON_RELEASE = "homeworks_button_release"
@@ -71,7 +70,7 @@ def setup(hass, base_config):
"""Dispatch state changes."""
_LOGGER.debug("callback: %s, %s", msg_type, values)
addr = values[0]
- signal = ENTITY_SIGNAL.format(addr)
+ signal = f"homeworks_entity_{addr}"
dispatcher_send(hass, signal, msg_type, values)
config = base_config.get(DOMAIN)
@@ -132,7 +131,7 @@ class HomeworksKeypadEvent:
self._addr = addr
self._name = name
self._id = slugify(self._name)
- signal = ENTITY_SIGNAL.format(self._addr)
+ signal = f"homeworks_entity_{self._addr}"
async_dispatcher_connect(self._hass, signal, self._update_callback)
@callback
diff --git a/homeassistant/components/homeworks/light.py b/homeassistant/components/homeworks/light.py
index 2c0034ee986..56d5bcacc47 100644
--- a/homeassistant/components/homeworks/light.py
+++ b/homeassistant/components/homeworks/light.py
@@ -8,14 +8,7 @@ from homeassistant.const import CONF_NAME
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from . import (
- CONF_ADDR,
- CONF_DIMMERS,
- CONF_RATE,
- ENTITY_SIGNAL,
- HOMEWORKS_CONTROLLER,
- HomeworksDevice,
-)
+from . import CONF_ADDR, CONF_DIMMERS, CONF_RATE, HOMEWORKS_CONTROLLER, HomeworksDevice
_LOGGER = logging.getLogger(__name__)
@@ -47,7 +40,7 @@ class HomeworksLight(HomeworksDevice, Light):
async def async_added_to_hass(self):
"""Call when entity is added to hass."""
- signal = ENTITY_SIGNAL.format(self._addr)
+ signal = f"homeworks_entity_{self._addr}"
_LOGGER.debug("connecting %s", signal)
async_dispatcher_connect(self.hass, signal, self._update_callback)
self._controller.request_dimmer_level(self._addr)
diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py
index f8537bfe96a..ece8257a713 100644
--- a/homeassistant/components/honeywell/climate.py
+++ b/homeassistant/components/honeywell/climate.py
@@ -141,7 +141,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
_LOGGER.warning(
"The honeywell component has been deprecated for EU (i.e. non-US) "
"systems. For EU-based systems, use the evohome component, "
- "see: https://home-assistant.io/integrations/evohome"
+ "see: https://www.home-assistant.io/integrations/evohome"
)
diff --git a/homeassistant/components/hp_ilo/sensor.py b/homeassistant/components/hp_ilo/sensor.py
index 04c715dc010..888fa2423ad 100644
--- a/homeassistant/components/hp_ilo/sensor.py
+++ b/homeassistant/components/hp_ilo/sensor.py
@@ -90,9 +90,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
new_device = HpIloSensor(
hass=hass,
hp_ilo_data=hp_ilo_data,
- sensor_name="{} {}".format(
- config.get(CONF_NAME), monitored_variable[CONF_NAME]
- ),
+ sensor_name=f"{config.get(CONF_NAME)} {monitored_variable[CONF_NAME]}",
sensor_type=monitored_variable[CONF_SENSOR_TYPE],
sensor_value_template=monitored_variable.get(CONF_VALUE_TEMPLATE),
unit_of_measurement=monitored_variable.get(CONF_UNIT_OF_MEASUREMENT),
diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py
index b966f5ae6a1..679968d1b8d 100644
--- a/homeassistant/components/html5/notify.py
+++ b/homeassistant/components/html5/notify.py
@@ -392,7 +392,7 @@ class HTML5PushCallbackView(HomeAssistantView):
humanize_error(event_payload, ex),
)
- event_name = "{}.{}".format(NOTIFY_CALLBACK_EVENT, event_payload[ATTR_TYPE])
+ event_name = f"{NOTIFY_CALLBACK_EVENT}.{event_payload[ATTR_TYPE]}"
request.app["hass"].bus.fire(event_name, event_payload)
return self.json({"status": "ok", "event": event_payload[ATTR_TYPE]})
diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py
index 58814b77e2d..18d8ce72d91 100644
--- a/homeassistant/components/http/auth.py
+++ b/homeassistant/components/http/auth.py
@@ -29,20 +29,12 @@ def async_sign_path(hass, refresh_token_id, path, expiration):
secret = hass.data[DATA_SIGN_SECRET] = secrets.token_hex()
now = dt_util.utcnow()
- return "{}?{}={}".format(
- path,
- SIGN_QUERY_PARAM,
- jwt.encode(
- {
- "iss": refresh_token_id,
- "path": path,
- "iat": now,
- "exp": now + expiration,
- },
- secret,
- algorithm="HS256",
- ).decode(),
+ encoded = jwt.encode(
+ {"iss": refresh_token_id, "path": path, "iat": now, "exp": now + expiration},
+ secret,
+ algorithm="HS256",
)
+ return f"{path}?{SIGN_QUERY_PARAM}=" f"{encoded.decode()}"
@callback
diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py
index da406c071e4..38eda8e9b3f 100644
--- a/homeassistant/components/http/ban.py
+++ b/homeassistant/components/http/ban.py
@@ -96,9 +96,7 @@ async def process_wrong_login(request):
"""
remote_addr = request[KEY_REAL_IP]
- msg = "Login attempt or request with invalid authentication from {}".format(
- remote_addr
- )
+ msg = f"Login attempt or request with invalid authentication from {remote_addr}"
_LOGGER.warning(msg)
hass = request.app["hass"]
diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py
index e60091684d3..bb7c5816c77 100644
--- a/homeassistant/components/http/view.py
+++ b/homeassistant/components/http/view.py
@@ -144,9 +144,7 @@ def request_handler_factory(view, handler):
elif not isinstance(result, bytes):
assert (
False
- ), "Result should be None, string, bytes or Response. Got: {}".format(
- result
- )
+ ), f"Result should be None, string, bytes or Response. Got: {result}"
return web.Response(body=result, status=status_code)
diff --git a/homeassistant/components/htu21d/sensor.py b/homeassistant/components/htu21d/sensor.py
index 954ba60abbf..5bd77d4dcb2 100644
--- a/homeassistant/components/htu21d/sensor.py
+++ b/homeassistant/components/htu21d/sensor.py
@@ -8,7 +8,7 @@ import smbus # pylint: disable=import-error
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_NAME, TEMP_FAHRENHEIT
+from homeassistant.const import CONF_NAME, TEMP_FAHRENHEIT, UNIT_PERCENTAGE
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle
@@ -50,7 +50,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
dev = [
HTU21DSensor(sensor_handler, name, SENSOR_TEMPERATURE, temp_unit),
- HTU21DSensor(sensor_handler, name, SENSOR_HUMIDITY, "%"),
+ HTU21DSensor(sensor_handler, name, SENSOR_HUMIDITY, UNIT_PERCENTAGE),
]
async_add_entities(dev)
diff --git a/homeassistant/components/huawei_lte/.translations/lv.json b/homeassistant/components/huawei_lte/.translations/lv.json
new file mode 100644
index 00000000000..e276ee03f24
--- /dev/null
+++ b/homeassistant/components/huawei_lte/.translations/lv.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "Parole",
+ "url": "URL",
+ "username": "Lietot\u0101jv\u0101rds"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py
index d3b2d5b1abd..e4291ae7e67 100644
--- a/homeassistant/components/huawei_lte/__init__.py
+++ b/homeassistant/components/huawei_lte/__init__.py
@@ -63,8 +63,11 @@ from .const import (
KEY_DEVICE_INFORMATION,
KEY_DEVICE_SIGNAL,
KEY_DIALUP_MOBILE_DATASWITCH,
+ KEY_MONITORING_MONTH_STATISTICS,
KEY_MONITORING_STATUS,
KEY_MONITORING_TRAFFIC_STATISTICS,
+ KEY_NET_CURRENT_PLMN,
+ KEY_NET_NET_MODE,
KEY_WLAN_HOST_LIST,
NOTIFY_SUPPRESS_TIMEOUT,
SERVICE_CLEAR_TRAFFIC_STATISTICS,
@@ -81,8 +84,6 @@ _LOGGER = logging.getLogger(__name__)
# https://github.com/quandyfactory/dicttoxml/issues/60
logging.getLogger("dicttoxml").setLevel(logging.WARNING)
-DEFAULT_NAME_TEMPLATE = "Huawei {} {}"
-
SCAN_INTERVAL = timedelta(seconds=10)
NOTIFY_SCHEMA = vol.Any(
@@ -232,10 +233,15 @@ class Router:
self._get_data(
KEY_DIALUP_MOBILE_DATASWITCH, self.client.dial_up.mobile_dataswitch
)
+ self._get_data(
+ KEY_MONITORING_MONTH_STATISTICS, self.client.monitoring.month_statistics
+ )
self._get_data(KEY_MONITORING_STATUS, self.client.monitoring.status)
self._get_data(
KEY_MONITORING_TRAFFIC_STATISTICS, self.client.monitoring.traffic_statistics
)
+ 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.signal_update()
@@ -567,7 +573,7 @@ class HuaweiLteBaseEntity(Entity):
@property
def name(self) -> str:
"""Return entity name."""
- return DEFAULT_NAME_TEMPLATE.format(self.router.device_name, self._entity_name)
+ return f"Huawei {self.router.device_name} {self._entity_name}"
@property
def available(self) -> bool:
diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py
index 6d699420283..5279dd65b92 100644
--- a/homeassistant/components/huawei_lte/const.py
+++ b/homeassistant/components/huawei_lte/const.py
@@ -8,8 +8,6 @@ DEFAULT_NOTIFY_SERVICE_NAME = DOMAIN
UPDATE_SIGNAL = f"{DOMAIN}_update"
UPDATE_OPTIONS_SIGNAL = f"{DOMAIN}_options_update"
-UNIT_SECONDS = "s"
-
CONNECTION_TIMEOUT = 10
NOTIFY_SUPPRESS_TIMEOUT = 30
@@ -29,8 +27,11 @@ KEY_DEVICE_BASIC_INFORMATION = "device_basic_information"
KEY_DEVICE_INFORMATION = "device_information"
KEY_DEVICE_SIGNAL = "device_signal"
KEY_DIALUP_MOBILE_DATASWITCH = "dialup_mobile_dataswitch"
+KEY_MONITORING_MONTH_STATISTICS = "monitoring_month_statistics"
KEY_MONITORING_STATUS = "monitoring_status"
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"
BINARY_SENSOR_KEYS = {KEY_MONITORING_STATUS}
@@ -40,7 +41,11 @@ DEVICE_TRACKER_KEYS = {KEY_WLAN_HOST_LIST}
SENSOR_KEYS = {
KEY_DEVICE_INFORMATION,
KEY_DEVICE_SIGNAL,
+ KEY_MONITORING_MONTH_STATISTICS,
+ KEY_MONITORING_STATUS,
KEY_MONITORING_TRAFFIC_STATISTICS,
+ KEY_NET_CURRENT_PLMN,
+ KEY_NET_NET_MODE,
}
SWITCH_KEYS = {KEY_DIALUP_MOBILE_DATASWITCH}
diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json
index 8525b9eeaad..795b33485b6 100644
--- a/homeassistant/components/huawei_lte/manifest.json
+++ b/homeassistant/components/huawei_lte/manifest.json
@@ -5,7 +5,7 @@
"documentation": "https://www.home-assistant.io/integrations/huawei_lte",
"requirements": [
"getmac==0.8.1",
- "huawei-lte-api==1.4.7",
+ "huawei-lte-api==1.4.10",
"stringcase==1.2.0",
"url-normalize==1.4.1"
],
diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py
index 54c5441c6e2..84d8e72c2ff 100644
--- a/homeassistant/components/huawei_lte/sensor.py
+++ b/homeassistant/components/huawei_lte/sensor.py
@@ -10,15 +10,19 @@ from homeassistant.components.sensor import (
DEVICE_CLASS_SIGNAL_STRENGTH,
DOMAIN as SENSOR_DOMAIN,
)
-from homeassistant.const import CONF_URL, DATA_BYTES, STATE_UNKNOWN
+from homeassistant.const import CONF_URL, DATA_BYTES, STATE_UNKNOWN, TIME_SECONDS
from . import HuaweiLteBaseEntity
from .const import (
DOMAIN,
KEY_DEVICE_INFORMATION,
KEY_DEVICE_SIGNAL,
+ KEY_MONITORING_MONTH_STATISTICS,
+ KEY_MONITORING_STATUS,
KEY_MONITORING_TRAFFIC_STATISTICS,
- UNIT_SECONDS,
+ KEY_NET_CURRENT_PLMN,
+ KEY_NET_NET_MODE,
+ SENSOR_KEYS,
)
_LOGGER = logging.getLogger(__name__)
@@ -118,11 +122,40 @@ SENSOR_META = {
and "mdi:signal-cellular-2"
or "mdi:signal-cellular-3",
),
+ KEY_MONITORING_MONTH_STATISTICS: dict(
+ exclude=re.compile(r"^month(duration|lastcleartime)$", re.IGNORECASE)
+ ),
+ (KEY_MONITORING_MONTH_STATISTICS, "CurrentMonthDownload"): dict(
+ name="Current month download", unit=DATA_BYTES, icon="mdi:download"
+ ),
+ (KEY_MONITORING_MONTH_STATISTICS, "CurrentMonthUpload"): dict(
+ name="Current month upload", unit=DATA_BYTES, icon="mdi:upload"
+ ),
+ KEY_MONITORING_STATUS: dict(
+ include=re.compile(
+ r"^(currentwifiuser|(primary|secondary).*dns)$", re.IGNORECASE
+ )
+ ),
+ (KEY_MONITORING_STATUS, "CurrentWifiUser"): dict(
+ name="WiFi clients connected", icon="mdi:wifi"
+ ),
+ (KEY_MONITORING_STATUS, "PrimaryDns"): dict(
+ name="Primary DNS server", icon="mdi:ip"
+ ),
+ (KEY_MONITORING_STATUS, "SecondaryDns"): dict(
+ name="Secondary DNS server", icon="mdi:ip"
+ ),
+ (KEY_MONITORING_STATUS, "PrimaryIPv6Dns"): dict(
+ name="Primary IPv6 DNS server", icon="mdi:ip"
+ ),
+ (KEY_MONITORING_STATUS, "SecondaryIPv6Dns"): dict(
+ name="Secondary IPv6 DNS server", icon="mdi:ip"
+ ),
KEY_MONITORING_TRAFFIC_STATISTICS: dict(
exclude=re.compile(r"^showtraffic$", re.IGNORECASE)
),
(KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentConnectTime"): dict(
- name="Current connection duration", unit=UNIT_SECONDS, icon="mdi:timer"
+ name="Current connection duration", unit=TIME_SECONDS, icon="mdi:timer"
),
(KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentDownload"): dict(
name="Current connection download", unit=DATA_BYTES, icon="mdi:download"
@@ -131,7 +164,7 @@ SENSOR_META = {
name="Current connection upload", unit=DATA_BYTES, icon="mdi:upload"
),
(KEY_MONITORING_TRAFFIC_STATISTICS, "TotalConnectTime"): dict(
- name="Total connected duration", unit=UNIT_SECONDS, icon="mdi:timer"
+ name="Total connected duration", unit=TIME_SECONDS, icon="mdi:timer"
),
(KEY_MONITORING_TRAFFIC_STATISTICS, "TotalDownload"): dict(
name="Total download", unit=DATA_BYTES, icon="mdi:download"
@@ -139,6 +172,29 @@ SENSOR_META = {
(KEY_MONITORING_TRAFFIC_STATISTICS, "TotalUpload"): dict(
name="Total upload", unit=DATA_BYTES, icon="mdi:upload"
),
+ KEY_NET_CURRENT_PLMN: dict(exclude=re.compile(r"^(Rat|ShortName)$", re.IGNORECASE)),
+ (KEY_NET_CURRENT_PLMN, "State"): dict(
+ name="Operator search mode",
+ formatter=lambda x: ({"0": "Auto", "1": "Manual"}.get(x, "Unknown"), None),
+ ),
+ (KEY_NET_CURRENT_PLMN, "FullName"): dict(name="Operator name",),
+ (KEY_NET_CURRENT_PLMN, "Numeric"): dict(name="Operator code",),
+ KEY_NET_NET_MODE: dict(include=re.compile(r"^NetworkMode$", re.IGNORECASE)),
+ (KEY_NET_NET_MODE, "NetworkMode"): dict(
+ name="Preferred mode",
+ formatter=lambda x: (
+ {
+ "00": "4G/3G/2G",
+ "01": "2G",
+ "02": "3G",
+ "03": "4G",
+ "0301": "4G/2G",
+ "0302": "4G/3G",
+ "0201": "3G/2G",
+ }.get(x, "Unknown"),
+ None,
+ ),
+ ),
}
@@ -146,11 +202,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up from config entry."""
router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]]
sensors = []
- for key in (
- KEY_DEVICE_INFORMATION,
- KEY_DEVICE_SIGNAL,
- KEY_MONITORING_TRAFFIC_STATISTICS,
- ):
+ for key in SENSOR_KEYS:
items = router.data.get(key)
if not items:
continue
diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py
index d214c5509ea..77c24caa389 100644
--- a/homeassistant/components/hue/config_flow.py
+++ b/homeassistant/components/hue/config_flow.py
@@ -60,10 +60,8 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
if (
user_input is not None
and self.discovered_bridges is not None
- # pylint: disable=unsupported-membership-test
and user_input["id"] in self.discovered_bridges
):
- # pylint: disable=unsubscriptable-object
self.bridge = self.discovered_bridges[user_input["id"]]
await self.async_set_unique_id(self.bridge.id, raise_on_progress=False)
# We pass user input to link so it will attempt to link right away
diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py
index 253c0a2069c..e468c516676 100644
--- a/homeassistant/components/hue/light.py
+++ b/homeassistant/components/hue/light.py
@@ -1,11 +1,9 @@
"""Support for the Philips Hue lights."""
-import asyncio
from datetime import timedelta
from functools import partial
import logging
import random
-from aiohttp import client_exceptions
import aiohue
import async_timeout
@@ -172,13 +170,9 @@ async def async_safe_fetch(bridge, fetch_method):
return await bridge.async_request_call(fetch_method)
except aiohue.Unauthorized:
await bridge.handle_unauthorized_error()
- raise UpdateFailed
- except (
- asyncio.TimeoutError,
- aiohue.AiohueException,
- client_exceptions.ClientError,
- ):
- raise UpdateFailed
+ raise UpdateFailed("Unauthorized")
+ except (aiohue.AiohueException,) as err:
+ raise UpdateFailed(f"Hue error: {err}")
@callback
diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py
index ed27cff8eab..507415963a5 100644
--- a/homeassistant/components/hue/sensor_base.py
+++ b/homeassistant/components/hue/sensor_base.py
@@ -1,9 +1,7 @@
"""Support for the Philips Hue sensors as a platform."""
-import asyncio
from datetime import timedelta
import logging
-from aiohttp import client_exceptions
from aiohue import AiohueException, Unauthorized
from aiohue.sensors import TYPE_ZLL_PRESENCE
import async_timeout
@@ -60,9 +58,9 @@ class SensorManager:
)
except Unauthorized:
await self.bridge.handle_unauthorized_error()
- raise UpdateFailed
- except (asyncio.TimeoutError, AiohueException, client_exceptions.ClientError):
- raise UpdateFailed
+ raise UpdateFailed("Unauthorized")
+ except AiohueException as err:
+ raise UpdateFailed(f"Hue error: {err}")
async def async_register_component(self, binary, async_add_entities):
"""Register async_add_entities methods for components."""
diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py
index 57ed29d9780..65b7b1f6f6e 100644
--- a/homeassistant/components/hydrawise/__init__.py
+++ b/homeassistant/components/hydrawise/__init__.py
@@ -6,7 +6,12 @@ from hydrawiser.core import Hydrawiser
from requests.exceptions import ConnectTimeout, HTTPError
import voluptuous as vol
-from homeassistant.const import ATTR_ATTRIBUTION, CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL
+from homeassistant.const import (
+ ATTR_ATTRIBUTION,
+ CONF_ACCESS_TOKEN,
+ CONF_SCAN_INTERVAL,
+ TIME_MINUTES,
+)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
@@ -40,7 +45,7 @@ DEVICE_MAP = {
"manual_watering": ["Manual Watering", "mdi:water-pump", "", ""],
"next_cycle": ["Next Cycle", "mdi:calendar-clock", "", ""],
"status": ["Status", "", "connectivity", ""],
- "watering_time": ["Watering Time", "mdi:water-pump", "", "min"],
+ "watering_time": ["Watering Time", "mdi:water-pump", "", TIME_MINUTES],
"rain_sensor": ["Rain Sensor", "", "moisture", ""],
}
@@ -79,9 +84,7 @@ def setup(hass, config):
except (ConnectTimeout, HTTPError) as ex:
_LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex))
hass.components.persistent_notification.create(
- "Error: {}
"
- "You will need to restart hass after fixing."
- "".format(ex),
+ f"Error: {ex}
You will need to restart hass after fixing.",
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
@@ -114,10 +117,7 @@ class HydrawiseEntity(Entity):
"""Initialize the Hydrawise entity."""
self.data = data
self._sensor_type = sensor_type
- self._name = "{0} {1}".format(
- self.data["name"],
- DEVICE_MAP[self._sensor_type][DEVICE_MAP_INDEX.index("KEY_INDEX")],
- )
+ self._name = f"{self.data['name']} {DEVICE_MAP[self._sensor_type][DEVICE_MAP_INDEX.index('KEY_INDEX')]}"
self._state = None
@property
diff --git a/homeassistant/components/iammeter/__init__.py b/homeassistant/components/iammeter/__init__.py
new file mode 100644
index 00000000000..b53cc35197c
--- /dev/null
+++ b/homeassistant/components/iammeter/__init__.py
@@ -0,0 +1 @@
+"""Support for IamMeter Devices."""
diff --git a/homeassistant/components/iammeter/manifest.json b/homeassistant/components/iammeter/manifest.json
new file mode 100644
index 00000000000..e1b021c8ce0
--- /dev/null
+++ b/homeassistant/components/iammeter/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "iammeter",
+ "name": "IamMeter",
+ "documentation": "https://www.home-assistant.io/integrations/iammeter",
+ "codeowners": [
+ "@lewei50"
+ ],
+ "requirements": [
+ "iammeter==0.1.3"
+ ],
+ "dependencies": []
+}
diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py
new file mode 100644
index 00000000000..b043a6e9832
--- /dev/null
+++ b/homeassistant/components/iammeter/sensor.py
@@ -0,0 +1,130 @@
+"""Support for iammeter via local API."""
+import asyncio
+from datetime import timedelta
+import logging
+
+import async_timeout
+from iammeter import real_time_api
+from iammeter.power_meter import IamMeterError
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers import debounce
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_PORT = 80
+DEFAULT_DEVICE_NAME = "IamMeter"
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
+ {
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_DEVICE_NAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ }
+)
+
+SCAN_INTERVAL = timedelta(seconds=30)
+PLATFORM_TIMEOUT = 8
+
+
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+ """Platform setup."""
+ config_host = config[CONF_HOST]
+ config_port = config[CONF_PORT]
+ config_name = config[CONF_NAME]
+ try:
+ with async_timeout.timeout(PLATFORM_TIMEOUT):
+ api = await real_time_api(config_host, config_port)
+ except (IamMeterError, asyncio.TimeoutError):
+ _LOGGER.error("Device is not ready")
+ raise PlatformNotReady
+
+ async def async_update_data():
+ try:
+ with async_timeout.timeout(PLATFORM_TIMEOUT):
+ return await api.get_data()
+ except (IamMeterError, asyncio.TimeoutError):
+ raise UpdateFailed
+
+ coordinator = DataUpdateCoordinator(
+ hass,
+ _LOGGER,
+ name=DEFAULT_DEVICE_NAME,
+ update_method=async_update_data,
+ update_interval=SCAN_INTERVAL,
+ request_refresh_debouncer=debounce.Debouncer(
+ hass, _LOGGER, cooldown=0.3, immediate=True
+ ),
+ )
+ await coordinator.async_refresh()
+ entities = []
+ for sensor_name, (row, idx, unit) in api.iammeter.sensor_map().items():
+ serial_number = api.iammeter.serial_number
+ uid = f"{serial_number}-{row}-{idx}"
+ entities.append(IamMeter(coordinator, uid, sensor_name, unit, config_name))
+ async_add_entities(entities)
+
+
+class IamMeter(Entity):
+ """Class for a sensor."""
+
+ def __init__(self, coordinator, uid, sensor_name, unit, dev_name):
+ """Initialize an iammeter sensor."""
+ self.coordinator = coordinator
+ self.uid = uid
+ self.sensor_name = sensor_name
+ self.unit = unit
+ self.dev_name = dev_name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self.coordinator.data.data[self.sensor_name]
+
+ @property
+ def unique_id(self):
+ """Return unique id."""
+ return self.uid
+
+ @property
+ def name(self):
+ """Name of this iammeter attribute."""
+ return f"{self.dev_name} {self.sensor_name}"
+
+ @property
+ def icon(self):
+ """Icon for each sensor."""
+ return "mdi:flash"
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self.unit
+
+ @property
+ def should_poll(self):
+ """Poll needed."""
+ return False
+
+ @property
+ def available(self):
+ """Return if entity is available."""
+ return self.coordinator.last_update_success
+
+ async def async_added_to_hass(self):
+ """When entity is added to hass."""
+ self.coordinator.async_add_listener(self.async_write_ha_state)
+
+ async def async_will_remove_from_hass(self):
+ """When entity will be removed from hass."""
+ self.coordinator.async_remove_listener(self.async_write_ha_state)
+
+ async def async_update(self):
+ """Update the entity."""
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/iaqualink/.translations/lv.json b/homeassistant/components/iaqualink/.translations/lv.json
new file mode 100644
index 00000000000..0173f3373ad
--- /dev/null
+++ b/homeassistant/components/iaqualink/.translations/lv.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "Parole"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/icloud/.translations/en.json b/homeassistant/components/icloud/.translations/en.json
index 3b7da70bcaf..19a07a19c68 100644
--- a/homeassistant/components/icloud/.translations/en.json
+++ b/homeassistant/components/icloud/.translations/en.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Account already configured"
+ "already_configured": "Account already configured",
+ "no_device": "None of your devices have \"Find my iPhone\" activated"
},
"error": {
"login": "Login error: please check your email & password",
@@ -19,7 +20,8 @@
"user": {
"data": {
"password": "Password",
- "username": "Email"
+ "username": "Email",
+ "with_family": "With family"
},
"description": "Enter your credentials",
"title": "iCloud credentials"
diff --git a/homeassistant/components/icloud/.translations/lv.json b/homeassistant/components/icloud/.translations/lv.json
new file mode 100644
index 00000000000..6e642b85933
--- /dev/null
+++ b/homeassistant/components/icloud/.translations/lv.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "step": {
+ "trusted_device": {
+ "data": {
+ "trusted_device": "Uzticama ier\u012bce"
+ }
+ },
+ "user": {
+ "data": {
+ "password": "Parole",
+ "username": "E-pasts"
+ }
+ }
+ },
+ "title": "Apple iCloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/icloud/.translations/zh-Hans.json b/homeassistant/components/icloud/.translations/zh-Hans.json
new file mode 100644
index 00000000000..dd5592884be
--- /dev/null
+++ b/homeassistant/components/icloud/.translations/zh-Hans.json
@@ -0,0 +1,36 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u8d26\u6237\u5df2\u914d\u7f6e\u5b8c\u6210"
+ },
+ "error": {
+ "login": "\u767b\u5f55\u51fa\u9519\uff1a\u8bf7\u68c0\u67e5\u60a8\u7684\u7535\u5b50\u90ae\u7bb1\u548c\u5bc6\u7801",
+ "send_verification_code": "\u65e0\u6cd5\u53d1\u9001\u9a8c\u8bc1\u7801",
+ "validate_verification_code": "\u65e0\u6cd5\u9a8c\u8bc1\u9a8c\u8bc1\u7801\uff0c\u8bf7\u9009\u62e9\u53d7\u4fe1\u4efb\u7684\u8bbe\u5907\u5e76\u91cd\u65b0\u5f00\u59cb\u9a8c\u8bc1"
+ },
+ "step": {
+ "trusted_device": {
+ "data": {
+ "trusted_device": "\u53d7\u4fe1\u4efb\u7684\u8bbe\u5907"
+ },
+ "description": "\u9009\u62e9\u53d7\u4fe1\u4efb\u7684\u8bbe\u5907",
+ "title": "iCloud \u53d7\u4fe1\u4efb\u7684\u8bbe\u5907"
+ },
+ "user": {
+ "data": {
+ "password": "\u5bc6\u7801"
+ },
+ "description": "\u8bf7\u8f93\u5165\u51ed\u636e",
+ "title": "iCloud \u51ed\u636e"
+ },
+ "verification_code": {
+ "data": {
+ "verification_code": "\u9a8c\u8bc1\u7801"
+ },
+ "description": "\u8bf7\u8f93\u5165\u60a8\u521a\u521a\u4ece iCloud \u6536\u5230\u7684\u9a8c\u8bc1\u7801",
+ "title": "iCloud \u9a8c\u8bc1\u7801"
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py
index 1131a4eecc9..ba0f42432cc 100644
--- a/homeassistant/components/icloud/__init__.py
+++ b/homeassistant/components/icloud/__init__.py
@@ -14,8 +14,10 @@ from .account import IcloudAccount
from .const import (
CONF_GPS_ACCURACY_THRESHOLD,
CONF_MAX_INTERVAL,
+ CONF_WITH_FAMILY,
DEFAULT_GPS_ACCURACY_THRESHOLD,
DEFAULT_MAX_INTERVAL,
+ DEFAULT_WITH_FAMILY,
DOMAIN,
PLATFORMS,
STORAGE_KEY,
@@ -71,6 +73,7 @@ ACCOUNT_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_WITH_FAMILY, default=DEFAULT_WITH_FAMILY): cv.boolean,
vol.Optional(CONF_MAX_INTERVAL, default=DEFAULT_MAX_INTERVAL): cv.positive_int,
vol.Optional(
CONF_GPS_ACCURACY_THRESHOLD, default=DEFAULT_GPS_ACCURACY_THRESHOLD
@@ -110,6 +113,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
+ with_family = entry.data[CONF_WITH_FAMILY]
max_interval = entry.data[CONF_MAX_INTERVAL]
gps_accuracy_threshold = entry.data[CONF_GPS_ACCURACY_THRESHOLD]
@@ -120,7 +124,13 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool
icloud_dir = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
account = IcloudAccount(
- hass, username, password, icloud_dir, max_interval, gps_accuracy_threshold,
+ hass,
+ username,
+ password,
+ icloud_dir,
+ with_family,
+ max_interval,
+ gps_accuracy_threshold,
)
await hass.async_add_executor_job(account.setup)
diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py
index 789ae563482..50a3e74f78f 100644
--- a/homeassistant/components/icloud/account.py
+++ b/homeassistant/components/icloud/account.py
@@ -5,7 +5,11 @@ import operator
from typing import Dict
from pyicloud import PyiCloudService
-from pyicloud.exceptions import PyiCloudFailedLoginException, PyiCloudNoDevicesException
+from pyicloud.exceptions import (
+ PyiCloudFailedLoginException,
+ PyiCloudNoDevicesException,
+ PyiCloudServiceNotActivatedException,
+)
from pyicloud.services.findmyiphone import AppleDevice
from homeassistant.components.zone import async_active_zone
@@ -74,6 +78,7 @@ class IcloudAccount:
username: str,
password: str,
icloud_dir: Store,
+ with_family: bool,
max_interval: int,
gps_accuracy_threshold: int,
):
@@ -81,6 +86,7 @@ class IcloudAccount:
self.hass = hass
self._username = username
self._password = password
+ self._with_family = with_family
self._fetch_interval = max_interval
self._max_interval = max_interval
self._gps_accuracy_threshold = gps_accuracy_threshold
@@ -91,6 +97,7 @@ class IcloudAccount:
self._owner_fullname = None
self._family_members_fullname = {}
self._devices = {}
+ self._retried_fetch = False
self.listeners = []
@@ -98,7 +105,10 @@ class IcloudAccount:
"""Set up an iCloud account."""
try:
self.api = PyiCloudService(
- self._username, self._password, self._icloud_dir.path
+ self._username,
+ self._password,
+ self._icloud_dir.path,
+ with_family=self._with_family,
)
except PyiCloudFailedLoginException as error:
self.api = None
@@ -109,14 +119,10 @@ class IcloudAccount:
api_devices = self.api.devices
# Gets device owners infos
user_info = api_devices.response["userInfo"]
- except (KeyError, PyiCloudNoDevicesException):
+ except (PyiCloudServiceNotActivatedException, PyiCloudNoDevicesException):
_LOGGER.error("No iCloud device found")
raise ConfigEntryNotReady
- if DEVICE_STATUS_CODES.get(list(api_devices)[0][DEVICE_STATUS]) == "pending":
- _LOGGER.warning("Pending devices, trying again ...")
- raise ConfigEntryNotReady
-
self._owner_fullname = f"{user_info['firstName']} {user_info['lastName']}"
self._family_members_fullname = {}
@@ -148,28 +154,15 @@ class IcloudAccount:
)
return
- if DEVICE_STATUS_CODES.get(list(api_devices)[0][DEVICE_STATUS]) == "pending":
- _LOGGER.warning("Pending devices, trying again in 15s")
- self._fetch_interval = 0.25
- dispatcher_send(self.hass, self.signal_device_update)
- track_point_in_utc_time(
- self.hass,
- self.keep_alive,
- utcnow() + timedelta(minutes=self._fetch_interval),
- )
- return
-
# Gets devices infos
new_device = False
for device in api_devices:
status = device.status(DEVICE_STATUS_SET)
device_id = status[DEVICE_ID]
device_name = status[DEVICE_NAME]
- device_status = DEVICE_STATUS_CODES.get(status[DEVICE_STATUS], "error")
if (
- device_status == "pending"
- or status[DEVICE_BATTERY_STATUS] == "Unknown"
+ status[DEVICE_BATTERY_STATUS] == "Unknown"
or status.get(DEVICE_BATTERY_LEVEL) is None
):
continue
@@ -189,7 +182,16 @@ class IcloudAccount:
self._devices[device_id].update(status)
new_device = True
- self._fetch_interval = self._determine_interval()
+ if (
+ DEVICE_STATUS_CODES.get(list(api_devices)[0][DEVICE_STATUS]) == "pending"
+ and not self._retried_fetch
+ ):
+ _LOGGER.warning("Pending devices, trying again in 15s")
+ self._fetch_interval = 0.25
+ self._retried_fetch = True
+ else:
+ self._fetch_interval = self._determine_interval()
+ self._retried_fetch = False
dispatcher_send(self.hass, self.signal_device_update)
if new_device:
diff --git a/homeassistant/components/icloud/config_flow.py b/homeassistant/components/icloud/config_flow.py
index b3cb9c28181..052e5b98379 100644
--- a/homeassistant/components/icloud/config_flow.py
+++ b/homeassistant/components/icloud/config_flow.py
@@ -3,7 +3,12 @@ import logging
import os
from pyicloud import PyiCloudService
-from pyicloud.exceptions import PyiCloudException, PyiCloudFailedLoginException
+from pyicloud.exceptions import (
+ PyiCloudException,
+ PyiCloudFailedLoginException,
+ PyiCloudNoDevicesException,
+ PyiCloudServiceNotActivatedException,
+)
import voluptuous as vol
from homeassistant import config_entries
@@ -12,8 +17,10 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import (
CONF_GPS_ACCURACY_THRESHOLD,
CONF_MAX_INTERVAL,
+ CONF_WITH_FAMILY,
DEFAULT_GPS_ACCURACY_THRESHOLD,
DEFAULT_MAX_INTERVAL,
+ DEFAULT_WITH_FAMILY,
STORAGE_KEY,
STORAGE_VERSION,
)
@@ -36,7 +43,7 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
self.api = None
self._username = None
self._password = None
- self._account_name = None
+ self._with_family = None
self._max_interval = None
self._gps_accuracy_threshold = None
@@ -59,6 +66,10 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
vol.Required(
CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")
): str,
+ vol.Optional(
+ CONF_WITH_FAMILY,
+ default=user_input.get(CONF_WITH_FAMILY, DEFAULT_WITH_FAMILY),
+ ): bool,
}
),
errors=errors or {},
@@ -78,6 +89,7 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
self._username = user_input[CONF_USERNAME]
self._password = user_input[CONF_PASSWORD]
+ self._with_family = user_input.get(CONF_WITH_FAMILY, DEFAULT_WITH_FAMILY)
self._max_interval = user_input.get(CONF_MAX_INTERVAL, DEFAULT_MAX_INTERVAL)
self._gps_accuracy_threshold = user_input.get(
CONF_GPS_ACCURACY_THRESHOLD, DEFAULT_GPS_ACCURACY_THRESHOLD
@@ -90,7 +102,13 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
try:
self.api = await self.hass.async_add_executor_job(
- PyiCloudService, self._username, self._password, icloud_dir.path
+ PyiCloudService,
+ self._username,
+ self._password,
+ icloud_dir.path,
+ True,
+ None,
+ self._with_family,
)
except PyiCloudFailedLoginException as error:
_LOGGER.error("Error logging into iCloud service: %s", error)
@@ -101,11 +119,23 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
if self.api.requires_2sa:
return await self.async_step_trusted_device()
+ try:
+ devices = await self.hass.async_add_executor_job(
+ getattr, self.api, "devices"
+ )
+ if not devices:
+ raise PyiCloudNoDevicesException()
+ except (PyiCloudServiceNotActivatedException, PyiCloudNoDevicesException):
+ _LOGGER.error("No device found in the iCloud account: %s", self._username)
+ self.api = None
+ return self.async_abort(reason="no_device")
+
return self.async_create_entry(
title=self._username,
data={
CONF_USERNAME: self._username,
CONF_PASSWORD: self._password,
+ CONF_WITH_FAMILY: self._with_family,
CONF_MAX_INTERVAL: self._max_interval,
CONF_GPS_ACCURACY_THRESHOLD: self._gps_accuracy_threshold,
},
@@ -195,6 +225,7 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
{
CONF_USERNAME: self._username,
CONF_PASSWORD: self._password,
+ CONF_WITH_FAMILY: self._with_family,
CONF_MAX_INTERVAL: self._max_interval,
CONF_GPS_ACCURACY_THRESHOLD: self._gps_accuracy_threshold,
}
diff --git a/homeassistant/components/icloud/const.py b/homeassistant/components/icloud/const.py
index 14bd4e498bd..d62bacf1212 100644
--- a/homeassistant/components/icloud/const.py
+++ b/homeassistant/components/icloud/const.py
@@ -2,9 +2,11 @@
DOMAIN = "icloud"
+CONF_WITH_FAMILY = "with_family"
CONF_MAX_INTERVAL = "max_interval"
CONF_GPS_ACCURACY_THRESHOLD = "gps_accuracy_threshold"
+DEFAULT_WITH_FAMILY = False
DEFAULT_MAX_INTERVAL = 30 # min
DEFAULT_GPS_ACCURACY_THRESHOLD = 500 # meters
diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py
index f1c27d3f79e..47a302e2f26 100644
--- a/homeassistant/components/icloud/device_tracker.py
+++ b/homeassistant/components/icloud/device_tracker.py
@@ -126,11 +126,6 @@ class IcloudTrackerEntity(TrackerEntity):
"model": self._device.device_model,
}
- @property
- def should_poll(self) -> bool:
- """No polling needed."""
- return False
-
async def async_added_to_hass(self):
"""Register state update callback."""
self._unsub_dispatcher = async_dispatcher_connect(
diff --git a/homeassistant/components/icloud/manifest.json b/homeassistant/components/icloud/manifest.json
index a4a51f9e1a2..b4ef46cfbaf 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.2"],
+ "requirements": ["pyicloud==0.9.5"],
"dependencies": [],
"codeowners": ["@Quentame"]
}
diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py
index 7dc699f6cb7..b2e8b4ead1e 100644
--- a/homeassistant/components/icloud/sensor.py
+++ b/homeassistant/components/icloud/sensor.py
@@ -3,7 +3,7 @@ import logging
from typing import Dict
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import DEVICE_CLASS_BATTERY
+from homeassistant.const import DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
@@ -83,7 +83,7 @@ class IcloudDeviceBatterySensor(Entity):
@property
def unit_of_measurement(self) -> str:
"""Battery state measured in percentage."""
- return "%"
+ return UNIT_PERCENTAGE
@property
def icon(self) -> str:
diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json
index e0a7b7a32ce..6cea7dc1175 100644
--- a/homeassistant/components/icloud/strings.json
+++ b/homeassistant/components/icloud/strings.json
@@ -7,7 +7,8 @@
"description": "Enter your credentials",
"data": {
"username": "Email",
- "password": "Password"
+ "password": "Password",
+ "with_family": "With family"
}
},
"trusted_device": {
@@ -31,7 +32,8 @@
"validate_verification_code": "Failed to verify your verification code, choose a trust device and start the verification again"
},
"abort": {
- "already_configured": "Account already configured"
+ "already_configured": "Account already configured",
+ "no_device": "None of your devices have \"Find my iPhone\" activated"
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py
index 3011f5a2a0a..72c905497c0 100644
--- a/homeassistant/components/ifttt/__init__.py
+++ b/homeassistant/components/ifttt/__init__.py
@@ -93,10 +93,19 @@ async def handle_webhook(hass, webhook_id, request):
try:
data = json.loads(body) if body else {}
except ValueError:
- return None
+ _LOGGER.error(
+ "Received invalid data from IFTTT. Data needs to be formatted as JSON: %s",
+ body,
+ )
+ return
- if isinstance(data, dict):
- data["webhook_id"] = webhook_id
+ if not isinstance(data, dict):
+ _LOGGER.error(
+ "Received invalid data from IFTTT. Data needs to be a dictionary: %s", data
+ )
+ return
+
+ data["webhook_id"] = webhook_id
hass.bus.async_fire(EVENT_RECEIVED, data)
diff --git a/homeassistant/components/ign_sismologia/geo_location.py b/homeassistant/components/ign_sismologia/geo_location.py
index deecc389e7e..21e8e1c7412 100644
--- a/homeassistant/components/ign_sismologia/geo_location.py
+++ b/homeassistant/components/ign_sismologia/geo_location.py
@@ -37,9 +37,6 @@ DEFAULT_UNIT_OF_MEASUREMENT = "km"
SCAN_INTERVAL = timedelta(minutes=5)
-SIGNAL_DELETE_ENTITY = "ign_sismologia_delete_{}"
-SIGNAL_UPDATE_ENTITY = "ign_sismologia_update_{}"
-
SOURCE = "ign_sismologia"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@@ -124,11 +121,11 @@ class IgnSismologiaFeedEntityManager:
def _update_entity(self, external_id):
"""Update entity."""
- dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id))
+ dispatcher_send(self._hass, f"ign_sismologia_update_{external_id}")
def _remove_entity(self, external_id):
"""Remove entity."""
- dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id))
+ dispatcher_send(self._hass, f"ign_sismologia_delete_{external_id}")
class IgnSismologiaLocationEvent(GeolocationEvent):
@@ -154,12 +151,12 @@ class IgnSismologiaLocationEvent(GeolocationEvent):
"""Call when entity is added to hass."""
self._remove_signal_delete = async_dispatcher_connect(
self.hass,
- SIGNAL_DELETE_ENTITY.format(self._external_id),
+ f"ign_sismologia_delete_{self._external_id}",
self._delete_callback,
)
self._remove_signal_update = async_dispatcher_connect(
self.hass,
- SIGNAL_UPDATE_ENTITY.format(self._external_id),
+ f"ign_sismologia_update_{self._external_id}",
self._update_callback,
)
diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py
index 9acf710a58e..f200c9651f0 100644
--- a/homeassistant/components/ihc/__init__.py
+++ b/homeassistant/components/ihc/__init__.py
@@ -53,7 +53,6 @@ AUTO_SETUP_YAML = "ihc_auto_setup.yaml"
DOMAIN = "ihc"
IHC_CONTROLLER = "controller"
-IHC_DATA = "ihc{}"
IHC_INFO = "info"
IHC_PLATFORMS = ("binary_sensor", "light", "sensor", "switch")
@@ -236,7 +235,7 @@ def ihc_setup(hass, config, conf, controller_id):
# Manual configuration
get_manual_configuration(hass, config, conf, ihc_controller, controller_id)
# Store controller configuration
- ihc_key = IHC_DATA.format(controller_id)
+ ihc_key = f"ihc{controller_id}"
hass.data[ihc_key] = {IHC_CONTROLLER: ihc_controller, IHC_INFO: conf[CONF_INFO]}
setup_service_functions(hass, ihc_controller)
return True
diff --git a/homeassistant/components/ihc/binary_sensor.py b/homeassistant/components/ihc/binary_sensor.py
index 00e43008342..3f59d7981fb 100644
--- a/homeassistant/components/ihc/binary_sensor.py
+++ b/homeassistant/components/ihc/binary_sensor.py
@@ -2,7 +2,7 @@
from homeassistant.components.binary_sensor import BinarySensorDevice
from homeassistant.const import CONF_TYPE
-from . import IHC_CONTROLLER, IHC_DATA, IHC_INFO
+from . import IHC_CONTROLLER, IHC_INFO
from .const import CONF_INVERTING
from .ihcdevice import IHCDevice
@@ -18,7 +18,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
product = device["product"]
# Find controller that corresponds with device id
ctrl_id = device["ctrl_id"]
- ihc_key = IHC_DATA.format(ctrl_id)
+ ihc_key = f"ihc{ctrl_id}"
info = hass.data[ihc_key][IHC_INFO]
ihc_controller = hass.data[ihc_key][IHC_CONTROLLER]
diff --git a/homeassistant/components/ihc/light.py b/homeassistant/components/ihc/light.py
index cb8bb424c8e..af6b62c42ff 100644
--- a/homeassistant/components/ihc/light.py
+++ b/homeassistant/components/ihc/light.py
@@ -3,7 +3,7 @@ import logging
from homeassistant.components.light import ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light
-from . import IHC_CONTROLLER, IHC_DATA, IHC_INFO
+from . import IHC_CONTROLLER, IHC_INFO
from .const import CONF_DIMMABLE, CONF_OFF_ID, CONF_ON_ID
from .ihcdevice import IHCDevice
from .util import async_pulse, async_set_bool, async_set_int
@@ -22,7 +22,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
product = device["product"]
# Find controller that corresponds with device id
ctrl_id = device["ctrl_id"]
- ihc_key = IHC_DATA.format(ctrl_id)
+ ihc_key = f"ihc{ctrl_id}"
info = hass.data[ihc_key][IHC_INFO]
ihc_controller = hass.data[ihc_key][IHC_CONTROLLER]
ihc_off_id = product_cfg.get(CONF_OFF_ID)
diff --git a/homeassistant/components/ihc/sensor.py b/homeassistant/components/ihc/sensor.py
index 71c9fa12ba1..cb1688bc7be 100644
--- a/homeassistant/components/ihc/sensor.py
+++ b/homeassistant/components/ihc/sensor.py
@@ -2,7 +2,7 @@
from homeassistant.const import CONF_UNIT_OF_MEASUREMENT
from homeassistant.helpers.entity import Entity
-from . import IHC_CONTROLLER, IHC_DATA, IHC_INFO
+from . import IHC_CONTROLLER, IHC_INFO
from .ihcdevice import IHCDevice
@@ -17,7 +17,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
product = device["product"]
# Find controller that corresponds with device id
ctrl_id = device["ctrl_id"]
- ihc_key = IHC_DATA.format(ctrl_id)
+ ihc_key = f"ihc{ctrl_id}"
info = hass.data[ihc_key][IHC_INFO]
ihc_controller = hass.data[ihc_key][IHC_CONTROLLER]
unit = product_cfg[CONF_UNIT_OF_MEASUREMENT]
diff --git a/homeassistant/components/ihc/switch.py b/homeassistant/components/ihc/switch.py
index ebe9fcce37b..15994f13eb2 100644
--- a/homeassistant/components/ihc/switch.py
+++ b/homeassistant/components/ihc/switch.py
@@ -1,7 +1,7 @@
"""Support for IHC switches."""
from homeassistant.components.switch import SwitchDevice
-from . import IHC_CONTROLLER, IHC_DATA, IHC_INFO
+from . import IHC_CONTROLLER, IHC_INFO
from .const import CONF_OFF_ID, CONF_ON_ID
from .ihcdevice import IHCDevice
from .util import async_pulse, async_set_bool
@@ -18,7 +18,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
product = device["product"]
# Find controller that corresponds with device id
ctrl_id = device["ctrl_id"]
- ihc_key = IHC_DATA.format(ctrl_id)
+ ihc_key = f"ihc{ctrl_id}"
info = hass.data[ihc_key][IHC_INFO]
ihc_controller = hass.data[ihc_key][IHC_CONTROLLER]
ihc_off_id = product_cfg.get(CONF_OFF_ID)
diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py
index 307d5a22c1e..a97d2a1d02b 100644
--- a/homeassistant/components/imap_email_content/sensor.py
+++ b/homeassistant/components/imap_email_content/sensor.py
@@ -113,7 +113,7 @@ class EmailReader:
self.connection.select(self._folder, readonly=True)
if not self._unread_ids:
- search = "SINCE {0:%d-%b-%Y}".format(datetime.date.today())
+ search = f"SINCE {datetime.date.today():%d-%b-%Y}"
if self._last_id is not None:
search = f"UID {self._last_id}:*"
diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py
index 150515cbbf5..f15c2298b9d 100644
--- a/homeassistant/components/incomfort/binary_sensor.py
+++ b/homeassistant/components/incomfort/binary_sensor.py
@@ -1,7 +1,10 @@
"""Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway."""
from typing import Any, Dict, Optional
-from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorDevice
+from homeassistant.components.binary_sensor import (
+ DOMAIN as BINARY_SENSOR_DOMAIN,
+ BinarySensorDevice,
+)
from . import DOMAIN, IncomfortChild
@@ -25,7 +28,7 @@ class IncomfortFailed(IncomfortChild, BinarySensorDevice):
super().__init__()
self._unique_id = f"{heater.serial_no}_failed"
- self.entity_id = ENTITY_ID_FORMAT.format(f"{DOMAIN}_failed")
+ self.entity_id = f"{BINARY_SENSOR_DOMAIN}.{DOMAIN}_failed"
self._name = "Boiler Fault"
self._client = client
diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py
index 23bda6b2fdf..7d91ca012b9 100644
--- a/homeassistant/components/incomfort/climate.py
+++ b/homeassistant/components/incomfort/climate.py
@@ -1,7 +1,7 @@
"""Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway."""
from typing import Any, Dict, List, Optional
-from homeassistant.components.climate import ENTITY_ID_FORMAT, ClimateDevice
+from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, ClimateDevice
from homeassistant.components.climate.const import (
HVAC_MODE_HEAT,
SUPPORT_TARGET_TEMPERATURE,
@@ -32,7 +32,7 @@ class InComfortClimate(IncomfortChild, ClimateDevice):
super().__init__()
self._unique_id = f"{heater.serial_no}_{room.room_no}"
- self.entity_id = ENTITY_ID_FORMAT.format(f"{DOMAIN}_{room.room_no}")
+ self.entity_id = f"{CLIMATE_DOMAIN}.{DOMAIN}_{room.room_no}"
self._name = f"Thermostat {room.room_no}"
self._client = client
diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py
index 4164225b0d7..692eecf2317 100644
--- a/homeassistant/components/incomfort/sensor.py
+++ b/homeassistant/components/incomfort/sensor.py
@@ -1,7 +1,7 @@
"""Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway."""
from typing import Any, Dict, Optional
-from homeassistant.components.sensor import ENTITY_ID_FORMAT
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import (
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
@@ -49,7 +49,7 @@ class IncomfortSensor(IncomfortChild):
self._heater = heater
self._unique_id = f"{heater.serial_no}_{slugify(name)}"
- self.entity_id = ENTITY_ID_FORMAT.format(f"{DOMAIN}_{slugify(name)}")
+ self.entity_id = f"{SENSOR_DOMAIN}.{DOMAIN}_{slugify(name)}"
self._name = f"Boiler {name}"
self._device_class = None
diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py
index 9096a7cb72c..88370acf166 100644
--- a/homeassistant/components/incomfort/water_heater.py
+++ b/homeassistant/components/incomfort/water_heater.py
@@ -5,7 +5,10 @@ from typing import Any, Dict
from aiohttp import ClientResponseError
-from homeassistant.components.water_heater import ENTITY_ID_FORMAT, WaterHeaterDevice
+from homeassistant.components.water_heater import (
+ DOMAIN as WATER_HEATER_DOMAIN,
+ WaterHeaterDevice,
+)
from homeassistant.const import TEMP_CELSIUS
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -35,7 +38,7 @@ class IncomfortWaterHeater(IncomfortEntity, WaterHeaterDevice):
super().__init__()
self._unique_id = f"{heater.serial_no}"
- self.entity_id = ENTITY_ID_FORMAT.format(DOMAIN)
+ self.entity_id = f"{WATER_HEATER_DOMAIN}.{DOMAIN}"
self._name = "Boiler"
self._client = client
diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py
index 4a169453e35..9d0eaa84340 100644
--- a/homeassistant/components/influxdb/sensor.py
+++ b/homeassistant/components/influxdb/sensor.py
@@ -196,9 +196,7 @@ class InfluxSensorData:
_LOGGER.error("Could not render where clause template: %s", ex)
return
- self.query = "select {}({}) as value from {} where {}".format(
- self.group, self.field, self.measurement, where_clause
- )
+ self.query = f"select {self.group}({self.field}) as value from {self.measurement} where {where_clause}"
_LOGGER.info("Running query: %s", self.query)
diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py
index daadfac3705..603aa826123 100644
--- a/homeassistant/components/input_boolean/__init__.py
+++ b/homeassistant/components/input_boolean/__init__.py
@@ -28,8 +28,6 @@ from homeassistant.loader import bind_hass
DOMAIN = "input_boolean"
-ENTITY_ID_FORMAT = DOMAIN + ".{}"
-
_LOGGER = logging.getLogger(__name__)
CONF_INITIAL = "initial"
@@ -155,7 +153,7 @@ class InputBoolean(ToggleEntity, RestoreEntity):
self._state = config.get(CONF_INITIAL)
if from_yaml:
self._editable = False
- self.entity_id = ENTITY_ID_FORMAT.format(self.unique_id)
+ self.entity_id = f"{DOMAIN}.{self.unique_id}"
@property
def should_poll(self):
diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py
index 371e0dea185..d000f606c58 100644
--- a/homeassistant/components/input_datetime/__init__.py
+++ b/homeassistant/components/input_datetime/__init__.py
@@ -27,7 +27,6 @@ from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
DOMAIN = "input_datetime"
-ENTITY_ID_FORMAT = DOMAIN + ".{}"
CONF_HAS_DATE = "has_date"
CONF_HAS_TIME = "has_time"
@@ -219,7 +218,7 @@ class InputDatetime(RestoreEntity):
def from_yaml(cls, config: typing.Dict) -> "InputDatetime":
"""Return entity instance initialized from yaml storage."""
input_dt = cls(config)
- input_dt.entity_id = ENTITY_ID_FORMAT.format(config[CONF_ID])
+ input_dt.entity_id = f"{DOMAIN}.{config[CONF_ID]}"
input_dt.editable = False
return input_dt
@@ -238,12 +237,22 @@ class InputDatetime(RestoreEntity):
return
if self.has_date and self.has_time:
- self._current_datetime = dt_util.parse_datetime(old_state.state)
+ date_time = dt_util.parse_datetime(old_state.state)
+ if date_time is None:
+ self._current_datetime = dt_util.parse_datetime(DEFAULT_VALUE)
+ return
+ self._current_datetime = date_time
elif self.has_date:
date = dt_util.parse_date(old_state.state)
+ if date is None:
+ self._current_datetime = dt_util.parse_datetime(DEFAULT_VALUE)
+ return
self._current_datetime = datetime.datetime.combine(date, DEFAULT_TIME)
else:
time = dt_util.parse_time(old_state.state)
+ if time is None:
+ self._current_datetime = dt_util.parse_datetime(DEFAULT_VALUE)
+ return
self._current_datetime = datetime.datetime.combine(DEFAULT_DATE, time)
@property
diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py
index f78fc485e40..8ec26ea3956 100644
--- a/homeassistant/components/input_number/__init__.py
+++ b/homeassistant/components/input_number/__init__.py
@@ -26,7 +26,6 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType, ServiceC
_LOGGER = logging.getLogger(__name__)
DOMAIN = "input_number"
-ENTITY_ID_FORMAT = DOMAIN + ".{}"
CONF_INITIAL = "initial"
CONF_MIN = "min"
@@ -209,7 +208,7 @@ class InputNumber(RestoreEntity):
def from_yaml(cls, config: typing.Dict) -> "InputNumber":
"""Return entity instance initialized from yaml storage."""
input_num = cls(config)
- input_num.entity_id = ENTITY_ID_FORMAT.format(config[CONF_ID])
+ input_num.entity_id = f"{DOMAIN}.{config[CONF_ID]}"
input_num.editable = False
return input_num
@@ -288,44 +287,22 @@ class InputNumber(RestoreEntity):
async def async_set_value(self, value):
"""Set new value."""
num_value = float(value)
+
if num_value < self._minimum or num_value > self._maximum:
- _LOGGER.warning(
- "Invalid value: %s (range %s - %s)",
- num_value,
- self._minimum,
- self._maximum,
+ raise vol.Invalid(
+ f"Invalid value for {self.entity_id}: {value} (range {self._minimum} - {self._maximum})"
)
- return
+
self._current_value = num_value
self.async_write_ha_state()
async def async_increment(self):
"""Increment value."""
- new_value = self._current_value + self._step
- if new_value > self._maximum:
- _LOGGER.warning(
- "Invalid value: %s (range %s - %s)",
- new_value,
- self._minimum,
- self._maximum,
- )
- return
- self._current_value = new_value
- self.async_write_ha_state()
+ await self.async_set_value(min(self._current_value + self._step, self._maximum))
async def async_decrement(self):
"""Decrement value."""
- new_value = self._current_value - self._step
- if new_value < self._minimum:
- _LOGGER.warning(
- "Invalid value: %s (range %s - %s)",
- new_value,
- self._minimum,
- self._maximum,
- )
- return
- self._current_value = new_value
- self.async_write_ha_state()
+ await self.async_set_value(max(self._current_value - self._step, self._minimum))
async def async_update_config(self, config: typing.Dict) -> None:
"""Handle when the config is updated."""
diff --git a/homeassistant/components/input_number/reproduce_state.py b/homeassistant/components/input_number/reproduce_state.py
index 22a91f74000..a81c7041607 100644
--- a/homeassistant/components/input_number/reproduce_state.py
+++ b/homeassistant/components/input_number/reproduce_state.py
@@ -3,6 +3,8 @@ import asyncio
import logging
from typing import Iterable, Optional
+import voluptuous as vol
+
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import Context, State
from homeassistant.helpers.typing import HomeAssistantType
@@ -37,9 +39,13 @@ async def _async_reproduce_state(
service = SERVICE_SET_VALUE
service_data = {ATTR_ENTITY_ID: state.entity_id, ATTR_VALUE: state.state}
- await hass.services.async_call(
- DOMAIN, service, service_data, context=context, blocking=True
- )
+ try:
+ await hass.services.async_call(
+ DOMAIN, service, service_data, context=context, blocking=True
+ )
+ except vol.Invalid as err:
+ # If value out of range.
+ _LOGGER.warning("Unable to reproduce state for %s: %s", state.entity_id, err)
async def async_reproduce_states(
diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py
index 6044375d8a8..9269dc3a7f9 100644
--- a/homeassistant/components/input_select/__init__.py
+++ b/homeassistant/components/input_select/__init__.py
@@ -23,7 +23,6 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType, ServiceC
_LOGGER = logging.getLogger(__name__)
DOMAIN = "input_select"
-ENTITY_ID_FORMAT = DOMAIN + ".{}"
CONF_INITIAL = "initial"
CONF_OPTIONS = "options"
@@ -58,9 +57,7 @@ def _cv_input_select(cfg):
initial = cfg.get(CONF_INITIAL)
if initial is not None and initial not in options:
raise vol.Invalid(
- 'initial state "{}" is not part of the options: {}'.format(
- initial, ",".join(options)
- )
+ f"initial state {initial} is not part of the options: {','.join(options)}"
)
return cfg
@@ -201,7 +198,7 @@ class InputSelect(RestoreEntity):
def from_yaml(cls, config: typing.Dict) -> "InputSelect":
"""Return entity instance initialized from yaml storage."""
input_select = cls(config)
- input_select.entity_id = ENTITY_ID_FORMAT.format(config[CONF_ID])
+ input_select.entity_id = f"{DOMAIN}.{config[CONF_ID]}"
input_select.editable = False
return input_select
diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py
index bdb3e8a4bc9..c512bc221db 100644
--- a/homeassistant/components/input_text/__init__.py
+++ b/homeassistant/components/input_text/__init__.py
@@ -26,7 +26,6 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType, ServiceC
_LOGGER = logging.getLogger(__name__)
DOMAIN = "input_text"
-ENTITY_ID_FORMAT = DOMAIN + ".{}"
CONF_INITIAL = "initial"
CONF_MIN = "min"
@@ -89,24 +88,22 @@ def _cv_input_text(cfg):
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: cv.schema_with_slug_keys(
- vol.Any(
- vol.All(
- {
- vol.Optional(CONF_NAME): cv.string,
- vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.Coerce(int),
- vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.Coerce(int),
- vol.Optional(CONF_INITIAL, ""): cv.string,
- vol.Optional(CONF_ICON): cv.icon,
- vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
- vol.Optional(CONF_PATTERN): cv.string,
- vol.Optional(CONF_MODE, default=MODE_TEXT): vol.In(
- [MODE_TEXT, MODE_PASSWORD]
- ),
- },
- _cv_input_text,
- ),
- None,
- )
+ vol.All(
+ lambda value: value or {},
+ {
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.Coerce(int),
+ vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.Coerce(int),
+ vol.Optional(CONF_INITIAL, ""): cv.string,
+ vol.Optional(CONF_ICON): cv.icon,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+ vol.Optional(CONF_PATTERN): cv.string,
+ vol.Optional(CONF_MODE, default=MODE_TEXT): vol.In(
+ [MODE_TEXT, MODE_PASSWORD]
+ ),
+ },
+ _cv_input_text,
+ ),
)
},
extra=vol.ALLOW_EXTRA,
@@ -204,15 +201,8 @@ class InputText(RestoreEntity):
@classmethod
def from_yaml(cls, config: typing.Dict) -> "InputText":
"""Return entity instance initialized from yaml storage."""
- # set defaults for empty config
- config = {
- CONF_MAX: CONF_MAX_VALUE,
- CONF_MIN: CONF_MIN_VALUE,
- CONF_MODE: MODE_TEXT,
- **config,
- }
input_text = cls(config)
- input_text.entity_id = ENTITY_ID_FORMAT.format(config[CONF_ID])
+ input_text.entity_id = f"{DOMAIN}.{config[CONF_ID]}"
input_text.editable = False
return input_text
diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json
index 69c35477b8d..64c4b6a67be 100644
--- a/homeassistant/components/insteon/manifest.json
+++ b/homeassistant/components/insteon/manifest.json
@@ -2,7 +2,7 @@
"domain": "insteon",
"name": "Insteon",
"documentation": "https://www.home-assistant.io/integrations/insteon",
- "requirements": ["insteonplm==0.16.7"],
+ "requirements": ["insteonplm==0.16.8"],
"dependencies": [],
"codeowners": []
}
diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py
index f195a458477..339189d3564 100644
--- a/homeassistant/components/insteon/utils.py
+++ b/homeassistant/components/insteon/utils.py
@@ -189,13 +189,13 @@ def async_register_services(hass, config, insteon_modem):
)
hass.services.async_register(DOMAIN, SRV_PRINT_IM_ALDB, print_im_aldb, schema=None)
hass.services.async_register(
- DOMAIN, SRV_X10_ALL_UNITS_OFF, x10_all_units_off, schema=X10_HOUSECODE_SCHEMA,
+ DOMAIN, SRV_X10_ALL_UNITS_OFF, x10_all_units_off, schema=X10_HOUSECODE_SCHEMA
)
hass.services.async_register(
- DOMAIN, SRV_X10_ALL_LIGHTS_OFF, x10_all_lights_off, schema=X10_HOUSECODE_SCHEMA,
+ DOMAIN, SRV_X10_ALL_LIGHTS_OFF, x10_all_lights_off, schema=X10_HOUSECODE_SCHEMA
)
hass.services.async_register(
- DOMAIN, SRV_X10_ALL_LIGHTS_ON, x10_all_lights_on, schema=X10_HOUSECODE_SCHEMA,
+ DOMAIN, SRV_X10_ALL_LIGHTS_ON, x10_all_lights_on, schema=X10_HOUSECODE_SCHEMA
)
hass.services.async_register(
DOMAIN, SRV_SCENE_ON, scene_on, schema=TRIGGER_SCENE_SCHEMA
@@ -223,17 +223,9 @@ def print_aldb_to_log(aldb):
in_use = "Y" if rec.control_flags.is_in_use else "N"
mode = "C" if rec.control_flags.is_controller else "R"
hwm = "Y" if rec.control_flags.is_high_water_mark else "N"
- _LOGGER.info(
- " {:04x} {:s} {:s} {:s} {:3d} {:s}"
- " {:3d} {:3d} {:3d}".format(
- rec.mem_addr,
- in_use,
- mode,
- hwm,
- rec.group,
- rec.address.human,
- rec.data1,
- rec.data2,
- rec.data3,
- )
+ log_msg = (
+ f" {rec.mem_addr:04x} {in_use:s} {mode:s} {hwm:s} "
+ f"{rec.group:3d} {rec.address.human:s} {rec.data1:3d} "
+ f"{rec.data2:3d} {rec.data3:3d}"
)
+ _LOGGER.info(log_msg)
diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py
index 560a7cbd33c..57ec6fefe29 100644
--- a/homeassistant/components/integration/sensor.py
+++ b/homeassistant/components/integration/sensor.py
@@ -10,6 +10,10 @@ from homeassistant.const import (
CONF_NAME,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
+ TIME_DAYS,
+ TIME_HOURS,
+ TIME_MINUTES,
+ TIME_SECONDS,
)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
@@ -38,7 +42,12 @@ INTEGRATION_METHOD = [TRAPEZOIDAL_METHOD, LEFT_METHOD, RIGHT_METHOD]
UNIT_PREFIXES = {None: 1, "k": 10 ** 3, "G": 10 ** 6, "T": 10 ** 9}
# SI Time prefixes
-UNIT_TIME = {"s": 1, "min": 60, "h": 60 * 60, "d": 24 * 60 * 60}
+UNIT_TIME = {
+ TIME_SECONDS: 1,
+ TIME_MINUTES: 60,
+ TIME_HOURS: 60 * 60,
+ TIME_DAYS: 24 * 60 * 60,
+}
ICON = "mdi:chart-histogram"
@@ -50,7 +59,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
vol.Required(CONF_SOURCE_SENSOR): cv.entity_id,
vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int),
vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES),
- vol.Optional(CONF_UNIT_TIME, default="h"): vol.In(UNIT_TIME),
+ vol.Optional(CONF_UNIT_TIME, default=TIME_HOURS): vol.In(UNIT_TIME),
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_METHOD, default=TRAPEZOIDAL_METHOD): vol.In(
INTEGRATION_METHOD
@@ -96,8 +105,8 @@ class IntegrationSensor(RestoreEntity):
self._name = name if name is not None else f"{source_entity} integral"
if unit_of_measurement is None:
- self._unit_template = "{}{}{}".format(
- "" if unit_prefix is None else unit_prefix, "{}", unit_time
+ self._unit_template = (
+ f"{'' if unit_prefix is None else unit_prefix}{{}}{unit_time}"
)
# we postpone the definition of unit_of_measurement to later
self._unit_of_measurement = None
diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py
index 63ed6a6ee26..62dd72973da 100644
--- a/homeassistant/components/ios/notify.py
+++ b/homeassistant/components/ios/notify.py
@@ -92,8 +92,8 @@ class iOSNotificationService(BaseNotificationService):
if req.status_code != 201:
fallback_error = req.json().get("errorMessage", "Unknown error")
- fallback_message = "Internal server error, please try again later: {}".format(
- fallback_error
+ fallback_message = (
+ f"Internal server error, please try again later: {fallback_error}"
)
message = req.json().get("message", fallback_message)
if req.status_code == 429:
diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py
index 47c54c3face..e12ab9b4a40 100644
--- a/homeassistant/components/ios/sensor.py
+++ b/homeassistant/components/ios/sensor.py
@@ -1,9 +1,13 @@
"""Support for Home Assistant iOS app sensors."""
from homeassistant.components import ios
+from homeassistant.const import UNIT_PERCENTAGE
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.icon import icon_for_battery_level
-SENSOR_TYPES = {"level": ["Battery Level", "%"], "state": ["Battery State", None]}
+SENSOR_TYPES = {
+ "level": ["Battery Level", UNIT_PERCENTAGE],
+ "state": ["Battery State", None],
+}
DEFAULT_ICON_LEVEL = "mdi:battery"
DEFAULT_ICON_STATE = "mdi:power-plug"
@@ -30,7 +34,7 @@ class IOSSensor(Entity):
def __init__(self, sensor_type, device_name, device):
"""Initialize the sensor."""
self._device_name = device_name
- self._name = "{} {}".format(device_name, SENSOR_TYPES[sensor_type][0])
+ self._name = f"{device_name} {SENSOR_TYPES[sensor_type][0]}"
self._device = device
self.type = sensor_type
self._state = None
@@ -56,7 +60,7 @@ class IOSSensor(Entity):
def name(self):
"""Return the name of the iOS sensor."""
device_name = self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_NAME]
- return "{} {}".format(device_name, SENSOR_TYPES[self.type][0])
+ return f"{device_name} {SENSOR_TYPES[self.type][0]}"
@property
def state(self):
diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py
index b298d356df8..70a15a0dac5 100644
--- a/homeassistant/components/iperf3/sensor.py
+++ b/homeassistant/components/iperf3/sensor.py
@@ -28,7 +28,7 @@ class Iperf3Sensor(RestoreEntity):
def __init__(self, iperf3_data, sensor_type):
"""Initialize the sensor."""
- self._name = "{} {}".format(SENSOR_TYPES[sensor_type][0], iperf3_data.host)
+ self._name = f"{SENSOR_TYPES[sensor_type][0]} {iperf3_data.host}"
self._state = None
self._sensor_type = sensor_type
self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
diff --git a/homeassistant/components/ipma/.translations/lv.json b/homeassistant/components/ipma/.translations/lv.json
new file mode 100644
index 00000000000..7ee56d5a419
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/lv.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "mode": "Re\u017e\u012bms"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/const.py b/homeassistant/components/ipma/const.py
index e68c12de13c..04064db2b88 100644
--- a/homeassistant/components/ipma/const.py
+++ b/homeassistant/components/ipma/const.py
@@ -7,7 +7,6 @@ DOMAIN = "ipma"
HOME_LOCATION_NAME = "Home"
-ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".ipma_{}"
-ENTITY_ID_SENSOR_FORMAT_HOME = ENTITY_ID_SENSOR_FORMAT.format(HOME_LOCATION_NAME)
+ENTITY_ID_SENSOR_FORMAT_HOME = f"{WEATHER_DOMAIN}.ipma_{HOME_LOCATION_NAME}"
_LOGGER = logging.getLogger(".")
diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json
index 1457ac24195..63c041f28c3 100644
--- a/homeassistant/components/ipma/manifest.json
+++ b/homeassistant/components/ipma/manifest.json
@@ -3,7 +3,7 @@
"name": "Instituto Português do Mar e Atmosfera (IPMA)",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ipma",
- "requirements": ["pyipma==2.0.4"],
+ "requirements": ["pyipma==2.0.5"],
"dependencies": [],
"codeowners": ["@dgomes", "@abmantis"]
}
diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py
index 512153fe1c2..a33dabeadeb 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, IQVIAError
+from pyiqvia.errors import InvalidZipError
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
@@ -59,13 +59,16 @@ FETCHER_MAPPING = {
CONFIG_SCHEMA = vol.Schema(
{
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_ZIP_CODE): str,
- vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All(
- cv.ensure_list, [vol.In(SENSORS)]
- ),
- }
+ DOMAIN: vol.All(
+ cv.deprecated(CONF_MONITORED_CONDITIONS, invalidation_version="0.114.0"),
+ vol.Schema(
+ {
+ vol.Required(CONF_ZIP_CODE): str,
+ vol.Optional(
+ CONF_MONITORED_CONDITIONS, default=list(SENSORS)
+ ): vol.All(cv.ensure_list, [vol.In(SENSORS)]),
+ }
+ ),
)
},
extra=vol.ALLOW_EXTRA,
@@ -100,10 +103,7 @@ async def async_setup_entry(hass, config_entry):
websession = aiohttp_client.async_get_clientsession(hass)
try:
- iqvia = IQVIAData(
- Client(config_entry.data[CONF_ZIP_CODE], websession),
- config_entry.data.get(CONF_MONITORED_CONDITIONS, list(SENSORS)),
- )
+ 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])
@@ -143,11 +143,10 @@ async def async_unload_entry(hass, config_entry):
class IQVIAData:
"""Define a data object to retrieve info from IQVIA."""
- def __init__(self, client, sensor_types):
+ def __init__(self, client):
"""Initialize."""
self._client = client
self.data = {}
- self.sensor_types = sensor_types
self.zip_code = client.zip_code
self.fetchers = Registry()
@@ -164,7 +163,7 @@ class IQVIAData:
tasks = {}
for conditions, fetcher_types in FETCHER_MAPPING.items():
- if not any(c in self.sensor_types for c in conditions):
+ if not any(c in SENSORS for c in conditions):
continue
for fetcher_type in fetcher_types:
@@ -173,7 +172,7 @@ class IQVIAData:
results = await asyncio.gather(*tasks.values(), return_exceptions=True)
for key, result in zip(tasks, results):
- if isinstance(result, IQVIAError):
+ if isinstance(result, Exception):
_LOGGER.error("Unable to get %s data: %s", key, result)
self.data[key] = {}
continue
diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py
index 09edca52895..1aae63a4908 100644
--- a/homeassistant/components/iqvia/sensor.py
+++ b/homeassistant/components/iqvia/sensor.py
@@ -66,7 +66,7 @@ async def async_setup_entry(hass, entry, async_add_entities):
}
sensors = []
- for sensor_type in iqvia.sensor_types:
+ 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))
@@ -134,21 +134,34 @@ class IndexSensor(IQVIAEntity):
async def async_update(self):
"""Update the sensor."""
if not self._iqvia.data:
+ _LOGGER.warning(
+ "IQVIA didn't return data for %s; trying again later", self.name
+ )
return
- data = {}
- if self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW):
- data = self._iqvia.data[TYPE_ALLERGY_INDEX].get("Location")
- elif self._type in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW):
- data = self._iqvia.data[TYPE_ASTHMA_INDEX].get("Location")
- elif self._type == TYPE_DISEASE_TODAY:
- data = self._iqvia.data[TYPE_DISEASE_INDEX].get("Location")
-
- if not data:
+ try:
+ if self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW):
+ data = self._iqvia.data[TYPE_ALLERGY_INDEX].get("Location")
+ elif self._type in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW):
+ data = self._iqvia.data[TYPE_ASTHMA_INDEX].get("Location")
+ elif self._type == TYPE_DISEASE_TODAY:
+ data = self._iqvia.data[TYPE_DISEASE_INDEX].get("Location")
+ except KeyError:
+ _LOGGER.warning(
+ "IQVIA didn't return data for %s; trying again later", self.name
+ )
return
key = self._type.split("_")[-1].title()
- [period] = [p for p in data["periods"] if p["Type"] == key]
+
+ try:
+ [period] = [p for p in data["periods"] if p["Type"] == key]
+ except ValueError:
+ _LOGGER.warning(
+ "IQVIA didn't return data for %s; trying again later", self.name
+ )
+ return
+
[rating] = [
i["label"]
for i in RATING_MAPPING
@@ -185,6 +198,6 @@ class IndexSensor(IQVIAEntity):
)
elif self._type == TYPE_DISEASE_TODAY:
for attrs in period["Triggers"]:
- self._attrs["{0}_index".format(attrs["Name"].lower())] = attrs["Index"]
+ self._attrs[f"{attrs['Name'].lower()}_index"] = attrs["Index"]
self._state = period["Index"]
diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py
index 883f4ed7b39..3bb7da52e22 100644
--- a/homeassistant/components/irish_rail_transport/sensor.py
+++ b/homeassistant/components/irish_rail_transport/sensor.py
@@ -6,7 +6,7 @@ from pyirishrail.pyirishrail import IrishRailRTPI
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -118,7 +118,7 @@ class IrishRailTransportSensor(Entity):
@property
def unit_of_measurement(self):
"""Return the unit this state is expressed in."""
- return "min"
+ return TIME_MINUTES
@property
def icon(self):
diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py
index 3f7de535407..076718e83a2 100644
--- a/homeassistant/components/islamic_prayer_times/sensor.py
+++ b/homeassistant/components/islamic_prayer_times/sensor.py
@@ -97,7 +97,7 @@ async def schedule_future_update(hass, sensors, midnight_time, prayer_times_data
now = dt_util.as_local(dt_util.now())
today = now.date()
- midnight_dt_str = "{}::{}".format(str(today), midnight_time)
+ midnight_dt_str = f"{today}::{midnight_time}"
midnight_dt = datetime.strptime(midnight_dt_str, "%Y-%m-%d::%H:%M")
if now > dt_util.as_local(midnight_dt):
@@ -166,12 +166,10 @@ class IslamicPrayerTimesData:
class IslamicPrayerTimeSensor(Entity):
"""Representation of an Islamic prayer time sensor."""
- ENTITY_ID_FORMAT = "sensor.islamic_prayer_time_{}"
-
def __init__(self, sensor_type, prayer_times_data):
"""Initialize the Islamic prayer time sensor."""
self.sensor_type = sensor_type
- self.entity_id = self.ENTITY_ID_FORMAT.format(self.sensor_type)
+ self.entity_id = f"sensor.islamic_prayer_time_{self.sensor_type}"
self.prayer_times_data = prayer_times_data
self._name = self.sensor_type.capitalize()
self._device_class = DEVICE_CLASS_TIMESTAMP
@@ -208,7 +206,7 @@ class IslamicPrayerTimeSensor(Entity):
def get_prayer_time_as_dt(prayer_time):
"""Create a datetime object for the respective prayer time."""
today = datetime.today().strftime("%Y-%m-%d")
- date_time_str = "{} {}".format(str(today), prayer_time)
+ date_time_str = f"{today} {prayer_time}"
pt_dt = dt_util.parse_datetime(date_time_str)
return pt_dt
diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py
index c1474334a8e..ebd1b0dbbb2 100644
--- a/homeassistant/components/isy994/__init__.py
+++ b/homeassistant/components/isy994/__init__.py
@@ -12,6 +12,7 @@ from homeassistant.const import (
CONF_PASSWORD,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
+ UNIT_PERCENTAGE,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, discovery
@@ -101,7 +102,7 @@ NODE_FILTERS = {
},
"light": {
"uom": ["51"],
- "states": ["on", "off", "%"],
+ "states": ["on", "off", UNIT_PERCENTAGE],
"node_def_id": [
"DimmerLampSwitch",
"DimmerLampSwitch_ADV",
@@ -141,6 +142,7 @@ NODE_FILTERS = {
"AlertModuleArmed",
"Siren",
"Siren_ADV",
+ "X10",
],
"insteon_type": ["2.", "9.10.", "9.11.", "113."],
},
@@ -529,5 +531,5 @@ class ISYDevice(Entity):
attr = {}
if hasattr(self._node, "aux_properties"):
for name, val in self._node.aux_properties.items():
- attr[name] = "{} {}".format(val.get("value"), val.get("uom"))
+ attr[name] = f"{val.get('value')} {val.get('uom')}"
return attr
diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py
index 7e69feb2f70..917dedd5c53 100644
--- a/homeassistant/components/isy994/binary_sensor.py
+++ b/homeassistant/components/isy994/binary_sensor.py
@@ -84,7 +84,7 @@ def _detect_device_type(node) -> str:
split_type = device_type.split(".")
for device_class, ids in ISY_DEVICE_TYPES.items():
- if "{}.{}".format(split_type[0], split_type[1]) in ids:
+ if f"{split_type[0]}.{split_type[1]}" in ids:
return device_class
return None
diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py
index a9746b004d0..9e2f3e90957 100644
--- a/homeassistant/components/isy994/sensor.py
+++ b/homeassistant/components/isy994/sensor.py
@@ -3,7 +3,24 @@ import logging
from typing import Callable
from homeassistant.components.sensor import DOMAIN
-from homeassistant.const import POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_UV_INDEX
+from homeassistant.const import (
+ CONCENTRATION_PARTS_PER_MILLION,
+ POWER_WATT,
+ SPEED_KILOMETERS_PER_HOUR,
+ SPEED_METERS_PER_SECOND,
+ SPEED_MILES_PER_HOUR,
+ TEMP_CELSIUS,
+ TEMP_FAHRENHEIT,
+ TIME_DAYS,
+ TIME_HOURS,
+ TIME_MILLISECONDS,
+ TIME_MINUTES,
+ TIME_MONTHS,
+ TIME_SECONDS,
+ TIME_YEARS,
+ UNIT_PERCENTAGE,
+ UNIT_UV_INDEX,
+)
from homeassistant.helpers.typing import ConfigType
from . import ISY994_NODES, ISY994_WEATHER, ISYDevice
@@ -12,22 +29,22 @@ _LOGGER = logging.getLogger(__name__)
UOM_FRIENDLY_NAME = {
"1": "amp",
- "3": "btu/h",
+ "3": f"btu/{TIME_HOURS}",
"4": TEMP_CELSIUS,
"5": "cm",
"6": "ft³",
- "7": "ft³/min",
+ "7": f"ft³/{TIME_MINUTES}",
"8": "m³",
- "9": "day",
- "10": "days",
+ "9": TIME_DAYS,
+ "10": TIME_DAYS,
"12": "dB",
"13": "dB A",
"14": "°",
"16": "macroseismic",
"17": TEMP_FAHRENHEIT,
"18": "ft",
- "19": "hour",
- "20": "hours",
+ "19": TIME_HOURS,
+ "20": TIME_HOURS,
"21": "abs. humidity (%)",
"22": "rel. humidity (%)",
"23": "inHg",
@@ -39,7 +56,7 @@ UOM_FRIENDLY_NAME = {
"29": "kV",
"30": "kW",
"31": "kPa",
- "32": "KPH",
+ "32": SPEED_KILOMETERS_PER_HOUR,
"33": "kWH",
"34": "liedu",
"35": "l",
@@ -47,24 +64,24 @@ UOM_FRIENDLY_NAME = {
"37": "mercalli",
"38": "m",
"39": "m³/hr",
- "40": "m/s",
+ "40": SPEED_METERS_PER_SECOND,
"41": "mA",
- "42": "ms",
+ "42": TIME_MILLISECONDS,
"43": "mV",
- "44": "min",
- "45": "min",
+ "44": TIME_MINUTES,
+ "45": TIME_MINUTES,
"46": "mm/hr",
- "47": "month",
- "48": "MPH",
- "49": "m/s",
+ "47": TIME_MONTHS,
+ "48": SPEED_MILES_PER_HOUR,
+ "49": SPEED_METERS_PER_SECOND,
"50": "ohm",
- "51": "%",
+ "51": UNIT_PERCENTAGE,
"52": "lb",
"53": "power factor",
- "54": "ppm",
+ "54": CONCENTRATION_PARTS_PER_MILLION,
"55": "pulse count",
- "57": "s",
- "58": "s",
+ "57": TIME_SECONDS,
+ "58": TIME_SECONDS,
"59": "seimens/m",
"60": "body wave magnitude scale",
"61": "Ricter scale",
@@ -79,7 +96,7 @@ UOM_FRIENDLY_NAME = {
"74": "W/m²",
"75": "weekday",
"76": "Wind Direction (°)",
- "77": "year",
+ "77": TIME_YEARS,
"82": "mm",
"83": "km",
"85": "ohm",
diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py
index 0e5dcddbc48..1b4d2f19d1c 100644
--- a/homeassistant/components/izone/__init__.py
+++ b/homeassistant/components/izone/__init__.py
@@ -1,9 +1,4 @@
-"""
-Platform for the iZone AC.
-
-For more details about this component, please refer to the documentation
-https://home-assistant.io/integrations/izone/
-"""
+"""Platform for the iZone AC."""
import logging
import voluptuous as vol
diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py
index b80dfc2542f..dde71d14a9c 100644
--- a/homeassistant/components/izone/climate.py
+++ b/homeassistant/components/izone/climate.py
@@ -85,6 +85,21 @@ async def async_setup_entry(
return True
+def _return_on_connection_error(ret=None):
+ def wrap(func):
+ def wrapped_f(*args, **kwargs):
+ if not args[0].available:
+ return ret
+ try:
+ return func(*args, **kwargs)
+ except ConnectionError:
+ return ret
+
+ return wrapped_f
+
+ return wrap
+
+
class ControllerDevice(ClimateDevice):
"""Representation of iZone Controller."""
@@ -161,6 +176,8 @@ class ControllerDevice(ClimateDevice):
if ctrl is not self._controller:
return
self.async_schedule_update_ha_state()
+ for zone in self.zones.values():
+ zone.async_schedule_update_ha_state()
self.async_on_remove(
async_dispatcher_connect(
@@ -259,12 +276,15 @@ class ControllerDevice(ClimateDevice):
if not self._controller.is_on:
return HVAC_MODE_OFF
mode = self._controller.mode
+ if mode == Controller.Mode.FREE_AIR:
+ return HVAC_MODE_FAN_ONLY
for (key, value) in self._state_to_pizone.items():
if value == mode:
return key
assert False, "Should be unreachable"
@property
+ @_return_on_connection_error([])
def hvac_modes(self) -> List[str]:
"""Return the list of available operation modes."""
if self._controller.free_air:
@@ -272,11 +292,13 @@ class ControllerDevice(ClimateDevice):
return [HVAC_MODE_OFF, *self._state_to_pizone]
@property
+ @_return_on_connection_error(PRESET_NONE)
def preset_mode(self):
"""Eco mode is external air."""
return PRESET_ECO if self._controller.free_air else PRESET_NONE
@property
+ @_return_on_connection_error([PRESET_NONE])
def preset_modes(self):
"""Available preset modes, normal or eco."""
if self._controller.free_air_enabled:
@@ -284,6 +306,7 @@ class ControllerDevice(ClimateDevice):
return [PRESET_NONE]
@property
+ @_return_on_connection_error()
def current_temperature(self) -> Optional[float]:
"""Return the current temperature."""
if self._controller.mode == Controller.Mode.FREE_AIR:
@@ -291,6 +314,7 @@ class ControllerDevice(ClimateDevice):
return self._controller.temp_return
@property
+ @_return_on_connection_error()
def target_temperature(self) -> Optional[float]:
"""Return the temperature we try to reach."""
if not self._supported_features & SUPPORT_TARGET_TEMPERATURE:
@@ -318,11 +342,13 @@ class ControllerDevice(ClimateDevice):
return list(self._fan_to_pizone)
@property
+ @_return_on_connection_error(0.0)
def min_temp(self) -> float:
"""Return the minimum temperature."""
return self._controller.temp_min
@property
+ @_return_on_connection_error(50.0)
def max_temp(self) -> float:
"""Return the maximum temperature."""
return self._controller.temp_max
@@ -437,7 +463,7 @@ class ZoneDevice(ClimateDevice):
@property
def unique_id(self):
"""Return the ID of the controller device."""
- return "{}_z{}".format(self._controller.unique_id, self._zone.index + 1)
+ return f"{self._controller.unique_id}_z{self._zone.index + 1}"
@property
def name(self) -> str:
@@ -453,14 +479,12 @@ class ZoneDevice(ClimateDevice):
return False
@property
+ @_return_on_connection_error(0)
def supported_features(self):
"""Return the list of supported features."""
- try:
- if self._zone.mode == Zone.Mode.AUTO:
- return self._supported_features
- return self._supported_features & ~SUPPORT_TARGET_TEMPERATURE
- except ConnectionError:
- return None
+ if self._zone.mode == Zone.Mode.AUTO:
+ return self._supported_features
+ return self._supported_features & ~SUPPORT_TARGET_TEMPERATURE
@property
def temperature_unit(self):
diff --git a/homeassistant/components/izone/const.py b/homeassistant/components/izone/const.py
index 4da7bc9e4af..fdee8dc7228 100644
--- a/homeassistant/components/izone/const.py
+++ b/homeassistant/components/izone/const.py
@@ -7,7 +7,7 @@ DATA_CONFIG = "izone_config"
DISPATCH_CONTROLLER_DISCOVERED = "izone_controller_discovered"
DISPATCH_CONTROLLER_DISCONNECTED = "izone_controller_disconnected"
-DISPATCH_CONTROLLER_RECONNECTED = "izone_controller_disconnected"
+DISPATCH_CONTROLLER_RECONNECTED = "izone_controller_reconnected"
DISPATCH_CONTROLLER_UPDATE = "izone_controller_update"
DISPATCH_ZONE_UPDATE = "izone_zone_update"
diff --git a/homeassistant/components/izone/manifest.json b/homeassistant/components/izone/manifest.json
index b8bb5b55b79..9e982b19cf8 100644
--- a/homeassistant/components/izone/manifest.json
+++ b/homeassistant/components/izone/manifest.json
@@ -2,7 +2,7 @@
"domain": "izone",
"name": "iZone",
"documentation": "https://www.home-assistant.io/integrations/izone",
- "requirements": ["python-izone==1.1.1"],
+ "requirements": ["python-izone==1.1.2"],
"dependencies": [],
"codeowners": ["@Swamp-Ig"],
"config_flow": true
diff --git a/homeassistant/components/juicenet/__init__.py b/homeassistant/components/juicenet/__init__.py
index 55bf91ac398..969e193bac8 100644
--- a/homeassistant/components/juicenet/__init__.py
+++ b/homeassistant/components/juicenet/__init__.py
@@ -65,4 +65,4 @@ class JuicenetDevice(Entity):
@property
def unique_id(self):
"""Return a unique ID."""
- return "{}-{}".format(self.device.id(), self.type)
+ return f"{self.device.id()}-{self.type}"
diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py
index 9a0431ef2d8..6ddb8279811 100644
--- a/homeassistant/components/juicenet/sensor.py
+++ b/homeassistant/components/juicenet/sensor.py
@@ -1,7 +1,7 @@
"""Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors."""
import logging
-from homeassistant.const import ENERGY_WATT_HOUR, POWER_WATT, TEMP_CELSIUS
+from homeassistant.const import ENERGY_WATT_HOUR, POWER_WATT, TEMP_CELSIUS, TIME_SECONDS
from homeassistant.helpers.entity import Entity
from . import DOMAIN, JuicenetDevice
@@ -14,7 +14,7 @@ SENSOR_TYPES = {
"voltage": ["Voltage", "V"],
"amps": ["Amps", "A"],
"watts": ["Watts", POWER_WATT],
- "charge_time": ["Charge time", "s"],
+ "charge_time": ["Charge time", TIME_SECONDS],
"energy_added": ["Energy added", ENERGY_WATT_HOUR],
}
@@ -43,7 +43,7 @@ class JuicenetSensorDevice(JuicenetDevice, Entity):
@property
def name(self):
"""Return the name of the device."""
- return "{} {}".format(self.device.name(), self._name)
+ return f"{self.device.name()} {self._name}"
@property
def icon(self):
diff --git a/homeassistant/components/kaiterra/const.py b/homeassistant/components/kaiterra/const.py
index 7e23edb1259..7f7eff99444 100644
--- a/homeassistant/components/kaiterra/const.py
+++ b/homeassistant/components/kaiterra/const.py
@@ -2,6 +2,14 @@
from datetime import timedelta
+from homeassistant.const import (
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_PARTS_PER_MILLION,
+ UNIT_PERCENTAGE,
+)
+
DOMAIN = "kaiterra"
DISPATCHER_KAITERRA = "kaiterra_update"
@@ -44,7 +52,16 @@ ATTR_AQI_LEVEL = "air_quality_index_level"
ATTR_AQI_POLLUTANT = "air_quality_index_pollutant"
AVAILABLE_AQI_STANDARDS = ["us", "cn", "in"]
-AVAILABLE_UNITS = ["x", "%", "C", "F", "mg/m³", "µg/m³", "ppm", "ppb"]
+AVAILABLE_UNITS = [
+ "x",
+ UNIT_PERCENTAGE,
+ "C",
+ "F",
+ CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ CONCENTRATION_PARTS_PER_MILLION,
+ CONCENTRATION_PARTS_PER_BILLION,
+]
AVAILABLE_DEVICE_TYPES = ["laseregg", "sensedge"]
CONF_AQI_STANDARD = "aqi_standard"
diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py
index 5640106eefa..edd42678a1f 100644
--- a/homeassistant/components/knx/__init__.py
+++ b/homeassistant/components/knx/__init__.py
@@ -102,7 +102,7 @@ async def async_setup(hass, config):
except XKNXException as ex:
_LOGGER.warning("Can't connect to KNX interface: %s", ex)
hass.components.persistent_notification.async_create(
- "Can't connect to KNX interface:
{0}".format(ex), title="KNX"
+ f"Can't connect to KNX interface:
{ex}", title="KNX"
)
for component, discovery_type in (
@@ -291,7 +291,7 @@ class KNXAutomation:
"""Initialize Automation class."""
self.hass = hass
self.device = device
- script_name = "{} turn ON script".format(device.get_name())
+ script_name = f"{device.get_name()} turn ON script"
self.script = Script(hass, action, script_name)
self.action = ActionCallback(
diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py
index f326ba60375..4fd86d078a0 100644
--- a/homeassistant/components/kodi/media_player.py
+++ b/homeassistant/components/kodi/media_player.py
@@ -183,7 +183,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
encryption = config.get(CONF_PROXY_SSL)
websocket = config.get(CONF_ENABLE_WEBSOCKET)
else:
- name = "{} ({})".format(DEFAULT_NAME, discovery_info.get("hostname"))
+ name = f"{DEFAULT_NAME} ({discovery_info.get('hostname')})"
host = discovery_info.get("host")
port = discovery_info.get("port")
tcp_port = DEFAULT_TCP_PORT
@@ -286,9 +286,7 @@ class KodiDevice(MediaPlayerDevice):
ws_protocol = "wss" if encryption else "ws"
self._http_url = f"{http_protocol}://{host}:{port}/jsonrpc"
- self._image_url = "{}://{}{}:{}/image".format(
- http_protocol, image_auth_string, host, port
- )
+ self._image_url = f"{http_protocol}://{image_auth_string}{host}:{port}/image"
self._ws_url = f"{ws_protocol}://{host}:{tcp_port}/jsonrpc"
self._http_server = jsonrpc_async.Server(self._http_url, **kwargs)
@@ -577,7 +575,7 @@ class KodiDevice(MediaPlayerDevice):
url_components = urllib.parse.urlparse(thumbnail)
if url_components.scheme == "image":
- return "{}/{}".format(self._image_url, urllib.parse.quote_plus(thumbnail))
+ return f"{self._image_url}/{urllib.parse.quote_plus(thumbnail)}"
@property
def media_title(self):
diff --git a/homeassistant/components/kodi/notify.py b/homeassistant/components/kodi/notify.py
index 6f370ffad98..e431763b8bd 100644
--- a/homeassistant/components/kodi/notify.py
+++ b/homeassistant/components/kodi/notify.py
@@ -44,7 +44,7 @@ ATTR_DISPLAYTIME = "displaytime"
async def async_get_service(hass, config, discovery_info=None):
"""Return the notify service."""
- url = "{}:{}".format(config.get(CONF_HOST), config.get(CONF_PORT))
+ url = f"{config.get(CONF_HOST)}:{config.get(CONF_PORT)}"
username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
@@ -58,7 +58,7 @@ async def async_get_service(hass, config, discovery_info=None):
_LOGGER.warning(
"Kodi host name should no longer contain http:// See updated "
"definitions here: "
- "https://home-assistant.io/components/media_player.kodi/"
+ "https://www.home-assistant.io/integrations/media_player.kodi/"
)
http_protocol = "https" if encryption else "http"
diff --git a/homeassistant/components/konnected/.translations/ca.json b/homeassistant/components/konnected/.translations/ca.json
index ccb03ef7add..fbfa9183941 100644
--- a/homeassistant/components/konnected/.translations/ca.json
+++ b/homeassistant/components/konnected/.translations/ca.json
@@ -14,6 +14,10 @@
"description": "Model: {model} \nAmfitri\u00f3: {host} \nPort: {port} \n\nPots configurar el comportament de les E/S (I/O) i del panell a la configuraci\u00f3 del panell d\u2019alarma Konnected.",
"title": "Dispositiu Konnected llest"
},
+ "import_confirm": {
+ "description": "S'ha descobert un panell d'alarma Konnected amb ID {id} a configuration.yaml. Aquest flux et permetr\u00e0 importar-lo a una entrada de configuraci\u00f3.",
+ "title": "Importaci\u00f3 de dispositiu Konnected"
+ },
"user": {
"data": {
"host": "Adre\u00e7a IP del dispositiu Konnected",
diff --git a/homeassistant/components/konnected/.translations/da.json b/homeassistant/components/konnected/.translations/da.json
index db37ad73610..a1545bd6575 100644
--- a/homeassistant/components/konnected/.translations/da.json
+++ b/homeassistant/components/konnected/.translations/da.json
@@ -11,9 +11,13 @@
},
"step": {
"confirm": {
- "description": "Model: {model}\nV\u00e6rt: {host}\nPort: {port}\n\nDu kan konfigurere IO og panelfunktionsm\u00e5den i indstillingerne for Konnected-alarmpanel.",
+ "description": "Model: {model}\nID: {id}\nV\u00e6rt: {host}\nPort: {port}\n\nDu kan konfigurere IO og panelfunktionsm\u00e5den i indstillingerne for Konnected-alarmpanel.",
"title": "Konnected-enhed klar"
},
+ "import_confirm": {
+ "description": "Et Konnected-alarmpanel med id {id} er blevet fundet i configuration.yaml. Dette flow giver dig mulighed for at importere det til en konfigurationspost.",
+ "title": "Importer Konnected-enhed"
+ },
"user": {
"data": {
"host": "Konnected-enhedens IP-adresse",
diff --git a/homeassistant/components/konnected/.translations/es.json b/homeassistant/components/konnected/.translations/es.json
index f72a58cf649..ed65b29a3b9 100644
--- a/homeassistant/components/konnected/.translations/es.json
+++ b/homeassistant/components/konnected/.translations/es.json
@@ -14,6 +14,10 @@
"description": "Modelo: {model}\nHost: {host}\nPuerto: {port}\n\nPuede configurar las E/S y el comportamiento del panel en los ajustes del panel de alarmas Konnected.",
"title": "Dispositivo Konnected Listo"
},
+ "import_confirm": {
+ "description": "Se ha descubierto un Panel de Alarma Konnected con ID {id} en configuration.yaml. Este flujo te permitir\u00e1 importarlo a una entrada de configuraci\u00f3n.",
+ "title": "Importar Dispositivo Konnected"
+ },
"user": {
"data": {
"host": "Direcci\u00f3n IP del dispositivo Konnected",
diff --git a/homeassistant/components/konnected/.translations/it.json b/homeassistant/components/konnected/.translations/it.json
index fb18ece10f8..08b15e031a5 100644
--- a/homeassistant/components/konnected/.translations/it.json
+++ b/homeassistant/components/konnected/.translations/it.json
@@ -11,9 +11,13 @@
},
"step": {
"confirm": {
- "description": "Modello: {model}\nHost: {host}\nPorta: {port}\n\n\u00c8 possibile configurare il comportamento di I/O e del pannello nelle impostazioni del Pannello Allarmi di Konnected.",
+ "description": "Modello: {model}\nID: {id}\nHost: {host}\nPorta: {port}\n\n\u00c8 possibile configurare il comportamento di I/O e del pannello nelle impostazioni del Pannello Allarmi di Konnected.",
"title": "Dispositivo Konnected pronto"
},
+ "import_confirm": {
+ "description": "In configuration.yaml \u00e8 stato individuato un pannello di allarme Konnected con ID {id}. Questo flusso consentir\u00e0 di importarlo in una voce di configurazione.",
+ "title": "Importa dispositivo Konnected"
+ },
"user": {
"data": {
"host": "Indirizzo IP del dispositivo Konnected",
diff --git a/homeassistant/components/konnected/.translations/lb.json b/homeassistant/components/konnected/.translations/lb.json
index 2e37ecb8e92..12493169691 100644
--- a/homeassistant/components/konnected/.translations/lb.json
+++ b/homeassistant/components/konnected/.translations/lb.json
@@ -14,6 +14,10 @@
"description": "Modell: {model}\nHost: {host}\nPort: {port}\n\nDir k\u00ebnnt den I/O a Panel Verhaalen an de Konnected Alarm Panel Astellunge konfigur\u00e9ieren.",
"title": "Konnected Apparat parat"
},
+ "import_confirm": {
+ "description": "Ee Konnected Alarm Pael mat der ID {id} gouf an der configuration.yaml entdeckt. D\u00ebsen Oflaf erlaabt et als eng Konfiguratioun z'import\u00e9ieren.",
+ "title": "Konnected Apparat import\u00e9ieren"
+ },
"user": {
"data": {
"host": "Konnected Apparat IP Adress",
diff --git a/homeassistant/components/konnected/.translations/no.json b/homeassistant/components/konnected/.translations/no.json
index 569dac5756f..72cd2911bbc 100644
--- a/homeassistant/components/konnected/.translations/no.json
+++ b/homeassistant/components/konnected/.translations/no.json
@@ -11,9 +11,13 @@
},
"step": {
"confirm": {
- "description": "Modell: {model}\nVert: {host}\nPort: {port}\n\nDu kan konfigurere IO og panel atferd i Konnected Alarm Panel innstillinger.",
+ "description": "Modell: {model}\nID: {id}\nVert: {host}\nPort: {port}\n\nDu kan konfigurere IO- og panelvirkem\u00e5ten i innstillingene for Konnected Alarm Panel.",
"title": "Konnected Enhet klar"
},
+ "import_confirm": {
+ "description": "Et Konnected Alarm Panel med ID {id} er oppdaget i configuration.yaml. Denne flyten vil tillate deg \u00e5 importere den til en config-oppf\u00f8ring.",
+ "title": "Importer Konnected Enhet"
+ },
"user": {
"data": {
"host": "Konnected enhet IP-adresse",
diff --git a/homeassistant/components/konnected/.translations/ru.json b/homeassistant/components/konnected/.translations/ru.json
index 25cb03b1578..ba1b3c6abc9 100644
--- a/homeassistant/components/konnected/.translations/ru.json
+++ b/homeassistant/components/konnected/.translations/ru.json
@@ -11,16 +11,20 @@
},
"step": {
"confirm": {
- "description": "\u041c\u043e\u0434\u0435\u043b\u044c: {model}\n\u0425\u043e\u0441\u0442: {host}\n\u041f\u043e\u0440\u0442: {port}\n\n\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435 \u043b\u043e\u0433\u0438\u043a\u0438 \u0440\u0430\u0431\u043e\u0442\u044b \u043f\u0430\u043d\u0435\u043b\u0438, \u0430 \u0442\u0430\u043a\u0436\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0432\u0445\u043e\u0434\u043e\u0432 \u0438 \u0432\u044b\u0445\u043e\u0434\u043e\u0432 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f \u0432 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u043f\u0430\u043d\u0435\u043b\u0438 \u0441\u0438\u0433\u043d\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 Konnected.",
+ "description": "\u041c\u043e\u0434\u0435\u043b\u044c: {model}\nID: {id}\n\u0425\u043e\u0441\u0442: {host}\n\u041f\u043e\u0440\u0442: {port}\n\n\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435 \u043b\u043e\u0433\u0438\u043a\u0438 \u0440\u0430\u0431\u043e\u0442\u044b \u043f\u0430\u043d\u0435\u043b\u0438, \u0430 \u0442\u0430\u043a\u0436\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0432\u0445\u043e\u0434\u043e\u0432 \u0438 \u0432\u044b\u0445\u043e\u0434\u043e\u0432 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f \u0432 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u043f\u0430\u043d\u0435\u043b\u0438 \u0441\u0438\u0433\u043d\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 Konnected.",
"title": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Konnected \u0433\u043e\u0442\u043e\u0432\u043e \u043a \u0440\u0430\u0431\u043e\u0442\u0435."
},
+ "import_confirm": {
+ "description": "\u041f\u0430\u043d\u0435\u043b\u044c \u0441\u0438\u0433\u043d\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 Konnected ID {id} \u0440\u0430\u043d\u0435\u0435 \u0443\u0436\u0435 \u0431\u044b\u043b\u0430 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0447\u0435\u0440\u0435\u0437 configuration.yaml. \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u044d\u0442\u0443 \u0437\u0430\u043f\u0438\u0441\u044c \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u0434\u0430\u043d\u043d\u043e\u0433\u043e \u043c\u0430\u0441\u0442\u0435\u0440\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438.",
+ "title": "\u0418\u043c\u043f\u043e\u0440\u0442 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Konnected"
+ },
"user": {
"data": {
"host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441",
"port": "\u041f\u043e\u0440\u0442"
},
"description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u043a \u043f\u0430\u043d\u0435\u043b\u0438 Konnected.",
- "title": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Konnected"
+ "title": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Konnected"
}
},
"title": "Konnected.io"
diff --git a/homeassistant/components/konnected/.translations/zh-Hant.json b/homeassistant/components/konnected/.translations/zh-Hant.json
index 0ecd6c9fc25..4c1bec691db 100644
--- a/homeassistant/components/konnected/.translations/zh-Hant.json
+++ b/homeassistant/components/konnected/.translations/zh-Hant.json
@@ -11,9 +11,13 @@
},
"step": {
"confirm": {
- "description": "\u578b\u865f\uff1a{model}\n\u4e3b\u6a5f\u7aef\uff1a{host}\n\u901a\u8a0a\u57e0\uff1a{port}\n\n\u53ef\u4ee5\u65bc Konncected \u8b66\u5831\u9762\u677f\u8a2d\u5b9a\u4e2d\u8a2d\u5b9a IO \u8207\u9762\u677f\u884c\u70ba\u3002",
+ "description": "\u578b\u865f\uff1a{model}\nID\uff1a{id}\n\u4e3b\u6a5f\u7aef\uff1a{host}\n\u901a\u8a0a\u57e0\uff1a{port}\n\n\u53ef\u4ee5\u65bc Konncected \u8b66\u5831\u9762\u677f\u8a2d\u5b9a\u4e2d\u8a2d\u5b9a IO \u8207\u9762\u677f\u884c\u70ba\u3002",
"title": "Konnected \u8a2d\u5099\u5df2\u5099\u59a5"
},
+ "import_confirm": {
+ "description": "\u65bc configuration.yaml \u4e2d\u767c\u73fe Konnected \u8b66\u5831 ID {id}\u3002\u6b64\u6d41\u7a0b\u5c07\u5141\u8a31\u532f\u5165\u81f3\u8a2d\u5b9a\u4e2d\u3002",
+ "title": "\u532f\u5165 Konnected \u8a2d\u5099"
+ },
"user": {
"data": {
"host": "Konnected \u8a2d\u5099 IP \u4f4d\u5740",
diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py
index 94508b01483..72d82fd31be 100644
--- a/homeassistant/components/konnected/__init__.py
+++ b/homeassistant/components/konnected/__init__.py
@@ -306,6 +306,7 @@ class KonnectedView(HomeAssistantView):
[
entry.data[CONF_ACCESS_TOKEN]
for entry in hass.config_entries.async_entries(DOMAIN)
+ if entry.data.get(CONF_ACCESS_TOKEN)
]
)
if auth is None or not next(
diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py
index dc4dae7787f..50f897e3a85 100644
--- a/homeassistant/components/konnected/binary_sensor.py
+++ b/homeassistant/components/konnected/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.const import (
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from .const import DOMAIN as KONNECTED_DOMAIN, SIGNAL_SENSOR_UPDATE
+from .const import DOMAIN as KONNECTED_DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -80,7 +80,7 @@ class KonnectedBinarySensor(BinarySensorDevice):
"""Store entity_id and register state change callback."""
self._data[ATTR_ENTITY_ID] = self.entity_id
async_dispatcher_connect(
- self.hass, SIGNAL_SENSOR_UPDATE.format(self.entity_id), self.async_set_state
+ self.hass, f"konnected.{self.entity_id}.update", self.async_set_state
)
@callback
diff --git a/homeassistant/components/konnected/const.py b/homeassistant/components/konnected/const.py
index d6819dcf71f..7cb0ffc5f80 100644
--- a/homeassistant/components/konnected/const.py
+++ b/homeassistant/components/konnected/const.py
@@ -46,5 +46,4 @@ ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()}
ENDPOINT_ROOT = "/api/konnected"
UPDATE_ENDPOINT = ENDPOINT_ROOT + r"/device/{device_id:[a-zA-Z0-9]+}"
-SIGNAL_SENSOR_UPDATE = "konnected.{}.update"
SIGNAL_DS18B20_NEW = "konnected.ds18b20.new"
diff --git a/homeassistant/components/konnected/handlers.py b/homeassistant/components/konnected/handlers.py
index a8914853e84..923e5d63899 100644
--- a/homeassistant/components/konnected/handlers.py
+++ b/homeassistant/components/konnected/handlers.py
@@ -10,7 +10,7 @@ from homeassistant.const import (
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.util import decorator
-from .const import CONF_INVERSE, SIGNAL_DS18B20_NEW, SIGNAL_SENSOR_UPDATE
+from .const import CONF_INVERSE, SIGNAL_DS18B20_NEW
_LOGGER = logging.getLogger(__name__)
HANDLERS = decorator.Registry()
@@ -25,7 +25,7 @@ async def async_handle_state_update(hass, context, msg):
if context.get(CONF_INVERSE):
state = not state
- async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state)
+ async_dispatcher_send(hass, f"konnected.{entity_id}.update", state)
@HANDLERS.register("temp")
@@ -34,7 +34,7 @@ async def async_handle_temp_update(hass, context, msg):
_LOGGER.debug("[temp handler] context: %s msg: %s", context, msg)
entity_id, temp = context.get(DEVICE_CLASS_TEMPERATURE), msg.get("temp")
if entity_id:
- async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE.format(entity_id), temp)
+ async_dispatcher_send(hass, f"konnected.{entity_id}.update", temp)
@HANDLERS.register("humi")
@@ -43,7 +43,7 @@ async def async_handle_humi_update(hass, context, msg):
_LOGGER.debug("[humi handler] context: %s msg: %s", context, msg)
entity_id, humi = context.get(DEVICE_CLASS_HUMIDITY), msg.get("humi")
if entity_id:
- async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE.format(entity_id), humi)
+ async_dispatcher_send(hass, f"konnected.{entity_id}.update", humi)
@HANDLERS.register("addr")
@@ -53,7 +53,7 @@ async def async_handle_addr_update(hass, context, msg):
addr, temp = msg.get("addr"), msg.get("temp")
entity_id = context.get(addr)
if entity_id:
- async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE.format(entity_id), temp)
+ async_dispatcher_send(hass, f"konnected.{entity_id}.update", temp)
else:
msg["device_id"] = context.get("device_id")
msg["temperature"] = temp
diff --git a/homeassistant/components/konnected/panel.py b/homeassistant/components/konnected/panel.py
index 2668a382ccc..783aa78b8b1 100644
--- a/homeassistant/components/konnected/panel.py
+++ b/homeassistant/components/konnected/panel.py
@@ -39,7 +39,6 @@ from .const import (
CONF_REPEAT,
DOMAIN,
ENDPOINT_ROOT,
- SIGNAL_SENSOR_UPDATE,
STATE_LOW,
ZONE_TO_PIN,
)
@@ -290,9 +289,7 @@ class AlarmPanel:
if sensor_config.get(CONF_INVERSE):
state = not state
- async_dispatcher_send(
- self.hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state
- )
+ async_dispatcher_send(self.hass, f"konnected.{entity_id}.update", state)
@callback
def async_desired_settings_payload(self):
diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py
index d189ac8809a..e936898d7fb 100644
--- a/homeassistant/components/konnected/sensor.py
+++ b/homeassistant/components/konnected/sensor.py
@@ -10,18 +10,19 @@ from homeassistant.const import (
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
-from .const import DOMAIN as KONNECTED_DOMAIN, SIGNAL_DS18B20_NEW, SIGNAL_SENSOR_UPDATE
+from .const import DOMAIN as KONNECTED_DOMAIN, SIGNAL_DS18B20_NEW
_LOGGER = logging.getLogger(__name__)
SENSOR_TYPES = {
DEVICE_CLASS_TEMPERATURE: ["Temperature", TEMP_CELSIUS],
- DEVICE_CLASS_HUMIDITY: ["Humidity", "%"],
+ DEVICE_CLASS_HUMIDITY: ["Humidity", UNIT_PERCENTAGE],
}
@@ -84,9 +85,7 @@ class KonnectedSensor(Entity):
self._type = sensor_type
self._zone_num = self._data.get(CONF_ZONE)
self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
- self._unique_id = addr or "{}-{}-{}".format(
- device_id, self._zone_num, sensor_type
- )
+ self._unique_id = addr or f"{device_id}-{self._zone_num}-{sensor_type}"
# set initial state if known at initialization
self._state = initial_state
@@ -121,16 +120,14 @@ class KonnectedSensor(Entity):
@property
def device_info(self):
"""Return the device info."""
- return {
- "identifiers": {(KONNECTED_DOMAIN, self._device_id)},
- }
+ return {"identifiers": {(KONNECTED_DOMAIN, self._device_id)}}
async def async_added_to_hass(self):
"""Store entity_id and register state change callback."""
entity_id_key = self._addr or self._type
self._data[entity_id_key] = self.entity_id
async_dispatcher_connect(
- self.hass, SIGNAL_SENSOR_UPDATE.format(self.entity_id), self.async_set_state
+ self.hass, f"konnected.{self.entity_id}.update", self.async_set_state
)
@callback
diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py
index d16051eb8da..b8ddec20440 100644
--- a/homeassistant/components/konnected/switch.py
+++ b/homeassistant/components/konnected/switch.py
@@ -48,8 +48,9 @@ class KonnectedSwitch(ToggleEntity):
self._repeat = self._data.get(CONF_REPEAT)
self._state = self._boolean_state(self._data.get(ATTR_STATE))
self._name = self._data.get(CONF_NAME)
- self._unique_id = "{}-{}-{}-{}-{}".format(
- device_id, self._zone_num, self._momentary, self._pause, self._repeat
+ self._unique_id = (
+ f"{device_id}-{self._zone_num}-{self._momentary}-"
+ f"{self._pause}-{self._repeat}"
)
@property
diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py
index b8bde797b39..1d38764710a 100644
--- a/homeassistant/components/lacrosse/sensor.py
+++ b/homeassistant/components/lacrosse/sensor.py
@@ -15,6 +15,7 @@ from homeassistant.const import (
CONF_TYPE,
EVENT_HOMEASSISTANT_STOP,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
@@ -193,7 +194,7 @@ class LaCrosseHumidity(LaCrosseSensor):
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
- return "%"
+ return UNIT_PERCENTAGE
@property
def state(self):
diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py
index 1a5b7a56e8e..80a72f1d6fd 100644
--- a/homeassistant/components/lastfm/sensor.py
+++ b/homeassistant/components/lastfm/sensor.py
@@ -89,7 +89,7 @@ class LastfmSensor(Entity):
top = self._user.get_top_tracks(limit=1)[0]
toptitle = re.search("', '(.+?)',", str(top))
topartist = re.search("'(.+?)',", str(top))
- self._topplayed = "{} - {}".format(topartist.group(1), toptitle.group(1))
+ self._topplayed = f"{topartist.group(1)} - {toptitle.group(1)}"
if self._user.get_now_playing() is None:
self._state = "Not Scrobbling"
return
diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py
index c49319abf42..0a46190fbf9 100644
--- a/homeassistant/components/lcn/const.py
+++ b/homeassistant/components/lcn/const.py
@@ -1,7 +1,7 @@
"""Constants for the LCN component."""
from itertools import product
-from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
+from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_PERCENTAGE
DOMAIN = "lcn"
DATA_LCN = "lcn"
@@ -92,9 +92,7 @@ BINSENSOR_PORTS = [
"BINSENSOR8",
]
-KEYS = [
- "{:s}{:d}".format(t[0], t[1]) for t in product(["A", "B", "C", "D"], range(1, 9))
-]
+KEYS = [f"{t[0]:s}{t[1]:d}" for t in product(["A", "B", "C", "D"], range(1, 9))]
VARIABLES = [
"VAR1ORTVAR",
@@ -155,7 +153,7 @@ VAR_UNITS = [
"LX",
"M/S",
"METERPERSECOND",
- "%",
+ UNIT_PERCENTAGE,
"PERCENT",
"PPM",
"VOLT",
diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py
index ba9d52b7721..1a5d4475b0e 100644
--- a/homeassistant/components/lcn/services.py
+++ b/homeassistant/components/lcn/services.py
@@ -7,6 +7,7 @@ from homeassistant.const import (
CONF_BRIGHTNESS,
CONF_STATE,
CONF_UNIT_OF_MEASUREMENT,
+ TIME_SECONDS,
)
import homeassistant.helpers.config_validation as cv
@@ -281,7 +282,7 @@ class SendKeys(LcnServiceCall):
vol.Upper, vol.In(SENDKEYCOMMANDS)
),
vol.Optional(CONF_TIME, default=0): vol.All(int, vol.Range(min=0)),
- vol.Optional(CONF_TIME_UNIT, default="s"): vol.All(
+ vol.Optional(CONF_TIME_UNIT, default=TIME_SECONDS): vol.All(
vol.Upper, vol.In(TIME_UNITS)
),
}
@@ -324,7 +325,7 @@ class LockKeys(LcnServiceCall):
),
vol.Required(CONF_STATE): is_key_lock_states_string,
vol.Optional(CONF_TIME, default=0): vol.All(int, vol.Range(min=0)),
- vol.Optional(CONF_TIME_UNIT, default="s"): vol.All(
+ vol.Optional(CONF_TIME_UNIT, default=TIME_SECONDS): vol.All(
vol.Upper, vol.In(TIME_UNITS)
),
}
diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py
index a0c40a59a46..50117c210a2 100644
--- a/homeassistant/components/life360/__init__.py
+++ b/homeassistant/components/life360/__init__.py
@@ -67,9 +67,7 @@ def _thresholds(config):
if error_threshold and warning_threshold:
if error_threshold <= warning_threshold:
raise vol.Invalid(
- "{} must be larger than {}".format(
- CONF_ERROR_THRESHOLD, CONF_WARNING_THRESHOLD
- )
+ f"{CONF_ERROR_THRESHOLD} must be larger than {CONF_WARNING_THRESHOLD}"
)
elif not error_threshold and warning_threshold:
config[CONF_ERROR_THRESHOLD] = warning_threshold + 1
diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py
index b7b0415a1b3..b6cd67c2627 100644
--- a/homeassistant/components/life360/device_tracker.py
+++ b/homeassistant/components/life360/device_tracker.py
@@ -5,9 +5,9 @@ import logging
from life360 import Life360Error
import voluptuous as vol
-from homeassistant.components.device_tracker import CONF_SCAN_INTERVAL
-from homeassistant.components.device_tracker.const import (
- ENTITY_ID_FORMAT as DT_ENTITY_ID_FORMAT,
+from homeassistant.components.device_tracker import (
+ CONF_SCAN_INTERVAL,
+ DOMAIN as DEVICE_TRACKER_DOMAIN,
)
from homeassistant.components.zone import async_active_zone
from homeassistant.const import (
@@ -72,7 +72,7 @@ def _include_name(filter_dict, name):
def _exc_msg(exc):
- return "{}: {}".format(exc.__class__.__name__, str(exc))
+ return f"{exc.__class__.__name__}: {exc}"
def _dump_filter(filter_dict, desc, func=lambda x: x):
@@ -180,14 +180,14 @@ class Life360Scanner:
if overdue and not reported and now - self._started > EVENT_DELAY:
self._hass.bus.fire(
EVENT_UPDATE_OVERDUE,
- {ATTR_ENTITY_ID: DT_ENTITY_ID_FORMAT.format(dev_id)},
+ {ATTR_ENTITY_ID: f"{DEVICE_TRACKER_DOMAIN}.{dev_id}"},
)
reported = True
elif not overdue and reported:
self._hass.bus.fire(
EVENT_UPDATE_RESTORED,
{
- ATTR_ENTITY_ID: DT_ENTITY_ID_FORMAT.format(dev_id),
+ ATTR_ENTITY_ID: f"{DEVICE_TRACKER_DOMAIN}.{dev_id}",
ATTR_WAIT: str(last_seen - (prev_seen or self._started)).split(
"."
)[0],
@@ -253,7 +253,7 @@ class Life360Scanner:
msg = f"Updating {dev_id}"
if prev_seen:
- msg += "; Time since last update: {}".format(last_seen - prev_seen)
+ msg += f"; Time since last update: {last_seen - prev_seen}"
_LOGGER.debug(msg)
if self._max_gps_accuracy is not None and gps_accuracy > self._max_gps_accuracy:
@@ -402,10 +402,10 @@ class Life360Scanner:
places = api.get_circle_places(circle_id)
place_data = "Circle's Places:"
for place in places:
- place_data += "\n- name: {}".format(place["name"])
- place_data += "\n latitude: {}".format(place["latitude"])
- place_data += "\n longitude: {}".format(place["longitude"])
- place_data += "\n radius: {}".format(place["radius"])
+ place_data += f"\n- name: {place['name']}"
+ place_data += f"\n latitude: {place['latitude']}"
+ place_data += f"\n longitude: {place['longitude']}"
+ place_data += f"\n radius: {place['radius']}"
if not places:
place_data += " None"
_LOGGER.debug(place_data)
diff --git a/homeassistant/components/lifx_cloud/scene.py b/homeassistant/components/lifx_cloud/scene.py
index 4068ff20fe2..08e044a46e0 100644
--- a/homeassistant/components/lifx_cloud/scene.py
+++ b/homeassistant/components/lifx_cloud/scene.py
@@ -14,7 +14,6 @@ import homeassistant.helpers.config_validation as cv
_LOGGER = logging.getLogger(__name__)
-LIFX_API_URL = "https://api.lifx.com/v1/{0}"
DEFAULT_TIMEOUT = 10
PLATFORM_SCHEMA = vol.Schema(
@@ -33,7 +32,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
headers = {AUTHORIZATION: f"Bearer {token}"}
- url = LIFX_API_URL.format("scenes")
+ url = "https://api.lifx.com/v1/scenes"
try:
httpsession = async_get_clientsession(hass)
@@ -78,7 +77,7 @@ class LifxCloudScene(Scene):
async def async_activate(self):
"""Activate the scene."""
- url = LIFX_API_URL.format("scenes/scene_id:%s/activate" % self._uuid)
+ url = f"https://api.lifx.com/v1/scenes/scene_id:{self._uuid}/activate"
try:
httpsession = async_get_clientsession(self.hass)
diff --git a/homeassistant/components/lifx_legacy/light.py b/homeassistant/components/lifx_legacy/light.py
index 8f767a2f559..7fb0e686b31 100644
--- a/homeassistant/components/lifx_legacy/light.py
+++ b/homeassistant/components/lifx_legacy/light.py
@@ -3,9 +3,6 @@ Support for the LIFX platform that implements lights.
This is a legacy platform, included because the current lifx platform does
not yet support Windows.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.lifx/
"""
import logging
diff --git a/homeassistant/components/light/.translations/ca.json b/homeassistant/components/light/.translations/ca.json
index b8b3bbb8125..ec7641e1cab 100644
--- a/homeassistant/components/light/.translations/ca.json
+++ b/homeassistant/components/light/.translations/ca.json
@@ -1,6 +1,8 @@
{
"device_automation": {
"action_type": {
+ "brightness_decrease": "Redueix la brillantor de {entity_name}",
+ "brightness_increase": "Augmenta la brillantor de {entity_name}",
"toggle": "Commuta {entity_name}",
"turn_off": "Apaga {entity_name}",
"turn_on": "Enc\u00e9n {entity_name}"
diff --git a/homeassistant/components/light/.translations/en.json b/homeassistant/components/light/.translations/en.json
index 3f37de5331e..788934a7e01 100644
--- a/homeassistant/components/light/.translations/en.json
+++ b/homeassistant/components/light/.translations/en.json
@@ -1,6 +1,8 @@
{
"device_automation": {
"action_type": {
+ "brightness_decrease": "Decrease {entity_name} brightness",
+ "brightness_increase": "Increase {entity_name} brightness",
"toggle": "Toggle {entity_name}",
"turn_off": "Turn off {entity_name}",
"turn_on": "Turn on {entity_name}"
diff --git a/homeassistant/components/light/.translations/es.json b/homeassistant/components/light/.translations/es.json
index 6bf91651d2e..f0996f9a523 100644
--- a/homeassistant/components/light/.translations/es.json
+++ b/homeassistant/components/light/.translations/es.json
@@ -1,6 +1,8 @@
{
"device_automation": {
"action_type": {
+ "brightness_decrease": "Disminuir brillo de {entity_name}",
+ "brightness_increase": "Aumentar brillo de {entity_name}",
"toggle": "Alternar {entity_name}",
"turn_off": "Apagar {entity_name}",
"turn_on": "Encender {entity_name}"
diff --git a/homeassistant/components/light/.translations/it.json b/homeassistant/components/light/.translations/it.json
index 2f4d2ca121f..ae1492d514e 100644
--- a/homeassistant/components/light/.translations/it.json
+++ b/homeassistant/components/light/.translations/it.json
@@ -1,6 +1,8 @@
{
"device_automation": {
"action_type": {
+ "brightness_decrease": "Riduci la luminosit\u00e0 di {entity_name}",
+ "brightness_increase": "Aumenta la luminosit\u00e0 di {entity_name}",
"toggle": "Commuta {entity_name}",
"turn_off": "Spegnere {entity_name}",
"turn_on": "Accendere {entity_name}"
diff --git a/homeassistant/components/light/.translations/lv.json b/homeassistant/components/light/.translations/lv.json
index 7668dfa5ac8..1436829ee9a 100644
--- a/homeassistant/components/light/.translations/lv.json
+++ b/homeassistant/components/light/.translations/lv.json
@@ -1,5 +1,9 @@
{
"device_automation": {
+ "action_type": {
+ "brightness_decrease": "Samazin\u0101t {entity_name} spilgtumu",
+ "brightness_increase": "Palielin\u0101t {entity_name} spilgtumu"
+ },
"trigger_type": {
"turned_off": "{entity_name} tika izsl\u0113gta",
"turned_on": "{entity_name} tika iesl\u0113gta"
diff --git a/homeassistant/components/light/.translations/no.json b/homeassistant/components/light/.translations/no.json
index 785e9ca2912..e7901ba51bc 100644
--- a/homeassistant/components/light/.translations/no.json
+++ b/homeassistant/components/light/.translations/no.json
@@ -1,6 +1,8 @@
{
"device_automation": {
"action_type": {
+ "brightness_decrease": "Reduser lysstyrken p\u00e5 {entity_name}",
+ "brightness_increase": "\u00d8k lysstyrken p\u00e5 {entity_name}",
"toggle": "Veksle {entity_name}",
"turn_off": "Sl\u00e5 av {entity_name}",
"turn_on": "Sl\u00e5 p\u00e5 {entity_name}"
diff --git a/homeassistant/components/light/.translations/ru.json b/homeassistant/components/light/.translations/ru.json
index 8ca964606ae..d6c3d037531 100644
--- a/homeassistant/components/light/.translations/ru.json
+++ b/homeassistant/components/light/.translations/ru.json
@@ -1,6 +1,8 @@
{
"device_automation": {
"action_type": {
+ "brightness_decrease": "\u0423\u043c\u0435\u043d\u044c\u0448\u0438\u0442\u044c \u044f\u0440\u043a\u043e\u0441\u0442\u044c {entity_name}",
+ "brightness_increase": "\u0423\u0432\u0435\u043b\u0438\u0447\u0438\u0442\u044c \u044f\u0440\u043a\u043e\u0441\u0442\u044c {entity_name}",
"toggle": "\u041f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}",
"turn_off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}",
"turn_on": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}"
diff --git a/homeassistant/components/light/.translations/zh-Hant.json b/homeassistant/components/light/.translations/zh-Hant.json
index d8bda90de85..228074abf06 100644
--- a/homeassistant/components/light/.translations/zh-Hant.json
+++ b/homeassistant/components/light/.translations/zh-Hant.json
@@ -1,6 +1,8 @@
{
"device_automation": {
"action_type": {
+ "brightness_decrease": "\u964d\u4f4e{entity_name}\u4eae\u5ea6",
+ "brightness_increase": "\u589e\u52a0{entity_name}\u4eae\u5ea6",
"toggle": "\u5207\u63db{entity_name}",
"turn_off": "\u95dc\u9589{entity_name}",
"turn_on": "\u958b\u555f{entity_name}"
diff --git a/homeassistant/components/light/device_action.py b/homeassistant/components/light/device_action.py
index 99f5b6b12bc..5c534cc4150 100644
--- a/homeassistant/components/light/device_action.py
+++ b/homeassistant/components/light/device_action.py
@@ -15,7 +15,7 @@ from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_registry
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
-from . import ATTR_BRIGHTNESS_STEP_PCT, DOMAIN, SUPPORT_BRIGHTNESS
+from . import ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS_STEP_PCT, DOMAIN, SUPPORT_BRIGHTNESS
TYPE_BRIGHTNESS_INCREASE = "brightness_increase"
TYPE_BRIGHTNESS_DECREASE = "brightness_decrease"
@@ -28,6 +28,9 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
toggle_entity.DEVICE_ACTION_TYPES
+ [TYPE_BRIGHTNESS_INCREASE, TYPE_BRIGHTNESS_DECREASE]
),
+ vol.Optional(ATTR_BRIGHTNESS_PCT): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=100)
+ ),
}
)
@@ -39,7 +42,10 @@ async def async_call_action_from_config(
context: Context,
) -> None:
"""Change state based on configuration."""
- if config[CONF_TYPE] in toggle_entity.DEVICE_ACTION_TYPES:
+ if (
+ config[CONF_TYPE] in toggle_entity.DEVICE_ACTION_TYPES
+ and config[CONF_TYPE] != toggle_entity.CONF_TURN_ON
+ ):
await toggle_entity.async_call_action_from_config(
hass, config, variables, context, DOMAIN
)
@@ -49,8 +55,10 @@ async def async_call_action_from_config(
if config[CONF_TYPE] == TYPE_BRIGHTNESS_INCREASE:
data[ATTR_BRIGHTNESS_STEP_PCT] = 10
- else:
+ elif config[CONF_TYPE] == TYPE_BRIGHTNESS_DECREASE:
data[ATTR_BRIGHTNESS_STEP_PCT] = -10
+ elif ATTR_BRIGHTNESS_PCT in config:
+ data[ATTR_BRIGHTNESS_PCT] = config[ATTR_BRIGHTNESS_PCT]
await hass.services.async_call(
DOMAIN, SERVICE_TURN_ON, data, blocking=True, context=context
@@ -93,3 +101,33 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]:
)
return actions
+
+
+async def async_get_action_capabilities(hass: HomeAssistant, config: dict) -> dict:
+ """List action capabilities."""
+ if config[CONF_TYPE] != toggle_entity.CONF_TURN_ON:
+ return {}
+
+ registry = await entity_registry.async_get_registry(hass)
+ entry = registry.async_get(config[ATTR_ENTITY_ID])
+ state = hass.states.get(config[ATTR_ENTITY_ID])
+
+ supported_features = 0
+
+ if state:
+ supported_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ elif entry:
+ supported_features = entry.supported_features
+
+ if not supported_features & SUPPORT_BRIGHTNESS:
+ return {}
+
+ return {
+ "extra_fields": vol.Schema(
+ {
+ vol.Optional(ATTR_BRIGHTNESS_PCT): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=100)
+ )
+ }
+ )
+ }
diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py
index c172ac1330a..58f74d8a422 100644
--- a/homeassistant/components/light/intent.py
+++ b/homeassistant/components/light/intent.py
@@ -51,14 +51,12 @@ class SetIntentHandler(intent.IntentHandler):
service_data[ATTR_RGB_COLOR] = slots["color"]["value"]
# Use original passed in value of the color because we don't have
# human readable names for that internally.
- speech_parts.append(
- "the color {}".format(intent_obj.slots["color"]["value"])
- )
+ speech_parts.append(f"the color {intent_obj.slots['color']['value']}")
if "brightness" in slots:
intent.async_test_feature(state, SUPPORT_BRIGHTNESS, "changing brightness")
service_data[ATTR_BRIGHTNESS_PCT] = slots["brightness"]["value"]
- speech_parts.append("{}% brightness".format(slots["brightness"]["value"]))
+ speech_parts.append(f"{slots['brightness']['value']}% brightness")
await hass.services.async_call(
DOMAIN, SERVICE_TURN_ON, service_data, context=intent_obj.context
diff --git a/homeassistant/components/light/manifest.json b/homeassistant/components/light/manifest.json
index e0a9652a10c..64e21654afd 100644
--- a/homeassistant/components/light/manifest.json
+++ b/homeassistant/components/light/manifest.json
@@ -3,7 +3,7 @@
"name": "Light",
"documentation": "https://www.home-assistant.io/integrations/light",
"requirements": [],
- "dependencies": ["group"],
+ "dependencies": [],
"codeowners": [],
"quality_scale": "internal"
}
diff --git a/homeassistant/components/linky/.translations/lv.json b/homeassistant/components/linky/.translations/lv.json
new file mode 100644
index 00000000000..973833a5470
--- /dev/null
+++ b/homeassistant/components/linky/.translations/lv.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "username": "E-pasts"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/linux_battery/sensor.py b/homeassistant/components/linux_battery/sensor.py
index bc02affdaed..bb1dd34dc00 100644
--- a/homeassistant/components/linux_battery/sensor.py
+++ b/homeassistant/components/linux_battery/sensor.py
@@ -6,7 +6,12 @@ from batinfo import Batteries
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import ATTR_NAME, CONF_NAME, DEVICE_CLASS_BATTERY
+from homeassistant.const import (
+ ATTR_NAME,
+ CONF_NAME,
+ DEVICE_CLASS_BATTERY,
+ UNIT_PERCENTAGE,
+)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -98,7 +103,7 @@ class LinuxBatterySensor(Entity):
@property
def unit_of_measurement(self):
"""Return the unit the value is expressed in."""
- return "%"
+ return UNIT_PERCENTAGE
@property
def device_state_attributes(self):
diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py
index ea36aa9f7fb..978f50b0ffd 100644
--- a/homeassistant/components/locative/__init__.py
+++ b/homeassistant/components/locative/__init__.py
@@ -93,9 +93,7 @@ async def handle_webhook(hass, webhook_id, request):
# before the previous zone was exited. The enter message will
# be sent first, then the exit message will be sent second.
return web.Response(
- text="Ignoring exit from {} (already in {})".format(
- location_name, current_state
- ),
+ text=f"Ignoring exit from {location_name} (already in {current_state})",
status=HTTP_OK,
)
diff --git a/homeassistant/components/locative/device_tracker.py b/homeassistant/components/locative/device_tracker.py
index ef247954171..49983d44eb9 100644
--- a/homeassistant/components/locative/device_tracker.py
+++ b/homeassistant/components/locative/device_tracker.py
@@ -61,11 +61,6 @@ class LocativeEntity(TrackerEntity):
"""Return the name of the device."""
return self._name
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
@property
def source_type(self):
"""Return the source type, eg gps or router, of the device."""
diff --git a/homeassistant/components/lock/manifest.json b/homeassistant/components/lock/manifest.json
index ab05166d15f..cd2fdf27f2d 100644
--- a/homeassistant/components/lock/manifest.json
+++ b/homeassistant/components/lock/manifest.json
@@ -3,7 +3,7 @@
"name": "Lock",
"documentation": "https://www.home-assistant.io/integrations/lock",
"requirements": [],
- "dependencies": ["group"],
+ "dependencies": [],
"codeowners": [],
"quality_scale": "internal"
}
diff --git a/homeassistant/components/lockitron/lock.py b/homeassistant/components/lockitron/lock.py
index 5840c7f5537..8ff8f430355 100644
--- a/homeassistant/components/lockitron/lock.py
+++ b/homeassistant/components/lockitron/lock.py
@@ -16,15 +16,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{vol.Required(CONF_ACCESS_TOKEN): cv.string, vol.Required(CONF_ID): cv.string}
)
BASE_URL = "https://api.lockitron.com"
-API_STATE_URL = BASE_URL + "/v2/locks/{}?access_token={}"
-API_ACTION_URL = BASE_URL + "/v2/locks/{}?access_token={}&state={}"
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Lockitron platform."""
access_token = config.get(CONF_ACCESS_TOKEN)
device_id = config.get(CONF_ID)
- response = requests.get(API_STATE_URL.format(device_id, access_token), timeout=5)
+ response = requests.get(
+ f"{BASE_URL}/v2/locks/{device_id}?access_token={access_token}", timeout=5
+ )
if response.status_code == 200:
add_entities([Lockitron(response.json()["state"], access_token, device_id)])
else:
@@ -64,7 +64,8 @@ class Lockitron(LockDevice):
def update(self):
"""Update the internal state of the device."""
response = requests.get(
- API_STATE_URL.format(self.device_id, self.access_token), timeout=5
+ f"{BASE_URL}/v2/locks/{self.device_id}?access_token={self.access_token}",
+ timeout=5,
)
if response.status_code == 200:
self._state = response.json()["state"]
@@ -74,7 +75,7 @@ class Lockitron(LockDevice):
def do_change_request(self, requested_state):
"""Execute the change request and pull out the new state."""
response = requests.put(
- API_ACTION_URL.format(self.device_id, self.access_token, requested_state),
+ f"{BASE_URL}/v2/locks/{self.device_id}?access_token={self.access_token}&state={requested_state}",
timeout=5,
)
if response.status_code == 200:
diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py
index 5d07a1fa0b5..9fad7e9752f 100644
--- a/homeassistant/components/logbook/__init__.py
+++ b/homeassistant/components/logbook/__init__.py
@@ -8,7 +8,6 @@ from sqlalchemy.exc import SQLAlchemyError
import voluptuous as vol
from homeassistant.components import sun
-from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME
from homeassistant.components.homekit.const import (
ATTR_DISPLAY_NAME,
ATTR_VALUE,
@@ -90,7 +89,6 @@ ALL_EVENT_TYPES = [
EVENT_LOGBOOK_ENTRY,
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
- EVENT_ALEXA_SMART_HOME,
EVENT_HOMEKIT_CHANGED,
EVENT_AUTOMATION_TRIGGERED,
EVENT_SCRIPT_STARTED,
@@ -124,6 +122,12 @@ def async_log_entry(hass, name, message, domain=None, entity_id=None):
hass.bus.async_fire(EVENT_LOGBOOK_ENTRY, data)
+@bind_hass
+def async_describe_event(hass, domain, event_name, describe_callback):
+ """Teach logbook how to describe a new event."""
+ hass.data.setdefault(DOMAIN, {})[event_name] = (domain, describe_callback)
+
+
async def async_setup(hass, config):
"""Listen for download events to download files."""
@@ -199,9 +203,6 @@ def humanify(hass, events):
"""
domain_prefixes = tuple(f"{dom}." for dom in CONTINUOUS_DOMAINS)
- # Track last states to filter out duplicates
- last_state = {}
-
# Group events in batches of GROUP_BY_MINUTES
for _, g_events in groupby(
events, lambda event: event.time_fired.minute // GROUP_BY_MINUTES
@@ -237,17 +238,20 @@ def humanify(hass, events):
start_stop_events[event.time_fired.minute] = 2
# Yield entries
+ external_events = hass.data.get(DOMAIN, {})
for event in events_batch:
+ if event.event_type in external_events:
+ domain, describe_event = external_events[event.event_type]
+ data = describe_event(event)
+ data["when"] = event.time_fired
+ data["domain"] = domain
+ data["context_id"] = event.context.id
+ data["context_user_id"] = event.context.user_id
+ yield data
+
if event.event_type == EVENT_STATE_CHANGED:
to_state = State.from_dict(event.data.get("new_state"))
- # Filter out states that become same state again (force_update=True)
- # or light becoming different color
- if last_state.get(to_state.entity_id) == to_state.state:
- continue
-
- last_state[to_state.entity_id] = to_state.state
-
domain = to_state.domain
# Skip all but the last sensor state
@@ -320,40 +324,13 @@ def humanify(hass, events):
"context_user_id": event.context.user_id,
}
- elif event.event_type == EVENT_ALEXA_SMART_HOME:
- data = event.data
- entity_id = data["request"].get("entity_id")
-
- if entity_id:
- state = hass.states.get(entity_id)
- name = state.name if state else entity_id
- message = "send command {}/{} for {}".format(
- data["request"]["namespace"], data["request"]["name"], name
- )
- else:
- message = "send command {}/{}".format(
- data["request"]["namespace"], data["request"]["name"]
- )
-
- yield {
- "when": event.time_fired,
- "name": "Amazon Alexa",
- "message": message,
- "domain": "alexa",
- "entity_id": entity_id,
- "context_id": event.context.id,
- "context_user_id": event.context.user_id,
- }
-
elif event.event_type == EVENT_HOMEKIT_CHANGED:
data = event.data
entity_id = data.get(ATTR_ENTITY_ID)
value = data.get(ATTR_VALUE)
value_msg = f" to {value}" if value else ""
- message = "send command {}{} for {}".format(
- data[ATTR_SERVICE], value_msg, data[ATTR_DISPLAY_NAME]
- )
+ message = f"send command {data[ATTR_SERVICE]}{value_msg} for {data[ATTR_DISPLAY_NAME]}"
yield {
"when": event.time_fired,
@@ -442,7 +419,7 @@ def _get_events(hass, config, start_day, end_day, entity_id=None):
"""Yield Events that are not filtered away."""
for row in query.yield_per(500):
event = row.to_native()
- if _keep_event(event, entities_filter):
+ if _keep_event(hass, event, entities_filter):
yield event
with session_scope(hass=hass) as session:
@@ -455,7 +432,9 @@ def _get_events(hass, config, start_day, end_day, entity_id=None):
session.query(Events)
.order_by(Events.time_fired)
.outerjoin(States, (Events.event_id == States.event_id))
- .filter(Events.event_type.in_(ALL_EVENT_TYPES))
+ .filter(
+ Events.event_type.in_(ALL_EVENT_TYPES + list(hass.data.get(DOMAIN, {})))
+ )
.filter((Events.time_fired > start_day) & (Events.time_fired < end_day))
.filter(
(
@@ -469,7 +448,7 @@ def _get_events(hass, config, start_day, end_day, entity_id=None):
return list(humanify(hass, yield_events(query)))
-def _keep_event(event, entities_filter):
+def _keep_event(hass, event, entities_filter):
domain, entity_id = None, None
if event.event_type == EVENT_STATE_CHANGED:
@@ -479,25 +458,21 @@ def _keep_event(event, entities_filter):
return False
# Do not report on new entities
- if event.data.get("old_state") is None:
+ old_state = event.data.get("old_state")
+ if old_state is None:
return False
- new_state = event.data.get("new_state")
-
# Do not report on entity removal
- if not new_state:
+ new_state = event.data.get("new_state")
+ if new_state is None:
return False
- attributes = new_state.get("attributes", {})
-
- # If last_changed != last_updated only attributes have changed
- # we do not report on that yet.
- last_changed = new_state.get("last_changed")
- last_updated = new_state.get("last_updated")
- if last_changed != last_updated:
+ # Do not report on only attribute changes
+ if new_state.get("state") == old_state.get("state"):
return False
domain = split_entity_id(entity_id)[0]
+ attributes = new_state.get("attributes", {})
# Also filter auto groups.
if domain == "group" and attributes.get("auto", False):
@@ -520,8 +495,8 @@ def _keep_event(event, entities_filter):
domain = "script"
entity_id = event.data.get(ATTR_ENTITY_ID)
- elif event.event_type == EVENT_ALEXA_SMART_HOME:
- domain = "alexa"
+ elif event.event_type in hass.data.get(DOMAIN, {}):
+ domain = hass.data[DOMAIN][event.event_type][0]
elif event.event_type == EVENT_HOMEKIT_CHANGED:
domain = DOMAIN_HOMEKIT
diff --git a/homeassistant/components/logi_circle/__init__.py b/homeassistant/components/logi_circle/__init__.py
index b77f17101a8..0a6d471889f 100644
--- a/homeassistant/components/logi_circle/__init__.py
+++ b/homeassistant/components/logi_circle/__init__.py
@@ -130,9 +130,10 @@ async def async_setup_entry(hass, entry):
if not logi_circle.authorized:
hass.components.persistent_notification.create(
- "Error: The cached access tokens are missing from {}.
"
- "Please unload then re-add the Logi Circle integration to resolve."
- "".format(DEFAULT_CACHEDB),
+ (
+ f"Error: The cached access tokens are missing from {DEFAULT_CACHEDB}.
"
+ f"Please unload then re-add the Logi Circle integration to resolve."
+ ),
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
@@ -158,18 +159,14 @@ async def async_setup_entry(hass, entry):
# string, so we'll handle it separately.
err = f"{_TIMEOUT}s timeout exceeded when connecting to Logi Circle API"
hass.components.persistent_notification.create(
- "Error: {}
"
- "You will need to restart hass after fixing."
- "".format(err),
+ f"Error: {err}
You will need to restart hass after fixing.",
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
return False
except ClientResponseError as ex:
hass.components.persistent_notification.create(
- "Error: {}
"
- "You will need to restart hass after fixing."
- "".format(ex),
+ f"Error: {ex}
You will need to restart hass after fixing.",
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
diff --git a/homeassistant/components/logi_circle/const.py b/homeassistant/components/logi_circle/const.py
index 4be4823f8d7..333a85e9b77 100644
--- a/homeassistant/components/logi_circle/const.py
+++ b/homeassistant/components/logi_circle/const.py
@@ -1,4 +1,5 @@
"""Constants in Logi Circle component."""
+from homeassistant.const import UNIT_PERCENTAGE
CONF_CLIENT_ID = "client_id"
CONF_CLIENT_SECRET = "client_secret"
@@ -15,11 +16,11 @@ RECORDING_MODE_KEY = "RECORDING_MODE"
# Sensor types: Name, unit of measure, icon per sensor key.
LOGI_SENSORS = {
- "battery_level": ["Battery", "%", "battery-50"],
+ "battery_level": ["Battery", UNIT_PERCENTAGE, "battery-50"],
"last_activity_time": ["Last Activity", None, "history"],
"recording": ["Recording Mode", None, "eye"],
"signal_strength_category": ["WiFi Signal Category", None, "wifi"],
- "signal_strength_percentage": ["WiFi Signal Strength", "%", "wifi"],
+ "signal_strength_percentage": ["WiFi Signal Strength", UNIT_PERCENTAGE, "wifi"],
"streaming": ["Streaming Mode", None, "camera"],
}
diff --git a/homeassistant/components/logi_circle/sensor.py b/homeassistant/components/logi_circle/sensor.py
index fc5ad7155b4..4a5fedaf57a 100644
--- a/homeassistant/components/logi_circle/sensor.py
+++ b/homeassistant/components/logi_circle/sensor.py
@@ -50,10 +50,8 @@ class LogiSensor(Entity):
self._sensor_type = sensor_type
self._camera = camera
self._id = f"{self._camera.mac_address}-{self._sensor_type}"
- self._icon = "mdi:{}".format(SENSOR_TYPES.get(self._sensor_type)[2])
- self._name = "{0} {1}".format(
- self._camera.name, SENSOR_TYPES.get(self._sensor_type)[0]
- )
+ self._icon = f"mdi:{SENSOR_TYPES.get(self._sensor_type)[2]}"
+ self._name = f"{self._camera.name} {SENSOR_TYPES.get(self._sensor_type)[0]}"
self._activity = {}
self._state = None
self._tz = time_zone
@@ -127,8 +125,8 @@ class LogiSensor(Entity):
last_activity = await self._camera.get_last_activity(force_refresh=True)
if last_activity is not None:
last_activity_time = as_local(last_activity.end_time_utc)
- self._state = "{0:0>2}:{1:0>2}".format(
- last_activity_time.hour, last_activity_time.minute
+ self._state = (
+ f"{last_activity_time.hour:0>2}:{last_activity_time.minute:0>2}"
)
else:
state = getattr(self._camera, self._sensor_type, None)
diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py
index b986c61ea36..95508c2f8f3 100644
--- a/homeassistant/components/lovelace/__init__.py
+++ b/homeassistant/components/lovelace/__init__.py
@@ -1,253 +1,254 @@
"""Support for the Lovelace UI."""
-from functools import wraps
import logging
-import os
-import time
import voluptuous as vol
-from homeassistant.components import websocket_api
+from homeassistant.components import frontend
+from homeassistant.config import async_hass_config_yaml, async_process_component_config
+from homeassistant.const import CONF_FILENAME
+from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.util.yaml import load_yaml
+from homeassistant.helpers import collection, config_validation as cv
+from homeassistant.helpers.service import async_register_admin_service
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType, ServiceCallType
+from homeassistant.loader import async_get_integration
+from homeassistant.util import sanitize_filename
+
+from . import dashboard, resources, websocket
+from .const import (
+ CONF_ICON,
+ CONF_MODE,
+ CONF_REQUIRE_ADMIN,
+ CONF_RESOURCES,
+ CONF_SHOW_IN_SIDEBAR,
+ CONF_TITLE,
+ CONF_URL_PATH,
+ DASHBOARD_BASE_CREATE_FIELDS,
+ DEFAULT_ICON,
+ DOMAIN,
+ MODE_STORAGE,
+ MODE_YAML,
+ RESOURCE_CREATE_FIELDS,
+ RESOURCE_RELOAD_SERVICE_SCHEMA,
+ RESOURCE_SCHEMA,
+ RESOURCE_UPDATE_FIELDS,
+ SERVICE_RELOAD_RESOURCES,
+ STORAGE_DASHBOARD_CREATE_FIELDS,
+ STORAGE_DASHBOARD_UPDATE_FIELDS,
+ url_slug,
+)
_LOGGER = logging.getLogger(__name__)
-DOMAIN = "lovelace"
-STORAGE_KEY = DOMAIN
-STORAGE_VERSION = 1
-CONF_MODE = "mode"
-MODE_YAML = "yaml"
-MODE_STORAGE = "storage"
+CONF_DASHBOARDS = "dashboards"
+
+YAML_DASHBOARD_SCHEMA = vol.Schema(
+ {
+ **DASHBOARD_BASE_CREATE_FIELDS,
+ vol.Required(CONF_MODE): MODE_YAML,
+ vol.Required(CONF_FILENAME): vol.All(cv.string, sanitize_filename),
+ }
+)
CONFIG_SCHEMA = vol.Schema(
{
- DOMAIN: vol.Schema(
+ vol.Optional(DOMAIN, default={}): vol.Schema(
{
vol.Optional(CONF_MODE, default=MODE_STORAGE): vol.All(
vol.Lower, vol.In([MODE_YAML, MODE_STORAGE])
- )
+ ),
+ vol.Optional(CONF_DASHBOARDS): cv.schema_with_slug_keys(
+ YAML_DASHBOARD_SCHEMA, slug_validator=url_slug,
+ ),
+ vol.Optional(CONF_RESOURCES): [RESOURCE_SCHEMA],
}
)
},
extra=vol.ALLOW_EXTRA,
)
-EVENT_LOVELACE_UPDATED = "lovelace_updated"
-LOVELACE_CONFIG_FILE = "ui-lovelace.yaml"
-
-
-class ConfigNotFound(HomeAssistantError):
- """When no config available."""
-
-
-async def async_setup(hass, config):
+async def async_setup(hass: HomeAssistantType, config: ConfigType):
"""Set up the Lovelace commands."""
- # Pass in default to `get` because defaults not set if loaded as dep
- mode = config.get(DOMAIN, {}).get(CONF_MODE, MODE_STORAGE)
+ mode = config[DOMAIN][CONF_MODE]
+ yaml_resources = config[DOMAIN].get(CONF_RESOURCES)
- hass.components.frontend.async_register_built_in_panel(
- DOMAIN, config={"mode": mode}
- )
+ frontend.async_register_built_in_panel(hass, DOMAIN, config={"mode": mode})
+
+ async def reload_resources_service_handler(service_call: ServiceCallType) -> None:
+ """Reload yaml resources."""
+ try:
+ conf = await async_hass_config_yaml(hass)
+ except HomeAssistantError as err:
+ _LOGGER.error(err)
+ return
+
+ integration = await async_get_integration(hass, DOMAIN)
+
+ config = await async_process_component_config(hass, conf, integration)
+
+ resource_collection = await create_yaml_resource_col(
+ hass, config[DOMAIN].get(CONF_RESOURCES)
+ )
+ hass.data[DOMAIN]["resources"] = resource_collection
if mode == MODE_YAML:
- hass.data[DOMAIN] = LovelaceYAML(hass)
+ default_config = dashboard.LovelaceYAML(hass, None, None)
+ resource_collection = await create_yaml_resource_col(hass, yaml_resources)
+
+ async_register_admin_service(
+ hass,
+ DOMAIN,
+ SERVICE_RELOAD_RESOURCES,
+ reload_resources_service_handler,
+ schema=RESOURCE_RELOAD_SERVICE_SCHEMA,
+ )
+
else:
- hass.data[DOMAIN] = LovelaceStorage(hass)
+ default_config = dashboard.LovelaceStorage(hass, None)
- hass.components.websocket_api.async_register_command(websocket_lovelace_config)
+ if yaml_resources is not None:
+ _LOGGER.warning(
+ "Lovelace is running in storage mode. Define resources via user interface"
+ )
- hass.components.websocket_api.async_register_command(websocket_lovelace_save_config)
+ resource_collection = resources.ResourceStorageCollection(hass, default_config)
+
+ collection.StorageCollectionWebsocket(
+ resource_collection,
+ "lovelace/resources",
+ "resource",
+ RESOURCE_CREATE_FIELDS,
+ RESOURCE_UPDATE_FIELDS,
+ ).async_setup(hass, create_list=False)
hass.components.websocket_api.async_register_command(
- websocket_lovelace_delete_config
+ websocket.websocket_lovelace_config
+ )
+ hass.components.websocket_api.async_register_command(
+ websocket.websocket_lovelace_save_config
+ )
+ hass.components.websocket_api.async_register_command(
+ websocket.websocket_lovelace_delete_config
+ )
+ hass.components.websocket_api.async_register_command(
+ websocket.websocket_lovelace_resources
+ )
+
+ hass.components.websocket_api.async_register_command(
+ websocket.websocket_lovelace_dashboards
)
hass.components.system_health.async_register_info(DOMAIN, system_health_info)
+ hass.data[DOMAIN] = {
+ # We store a dictionary mapping url_path: config. None is the default.
+ "dashboards": {None: default_config},
+ "resources": resource_collection,
+ "yaml_dashboards": config[DOMAIN].get(CONF_DASHBOARDS, {}),
+ }
+
+ if hass.config.safe_mode:
+ return True
+
+ async def storage_dashboard_changed(change_type, item_id, item):
+ """Handle a storage dashboard change."""
+ url_path = item[CONF_URL_PATH]
+
+ if change_type == collection.CHANGE_REMOVED:
+ frontend.async_remove_panel(hass, url_path)
+ await hass.data[DOMAIN]["dashboards"].pop(url_path).async_delete()
+ return
+
+ if change_type == collection.CHANGE_ADDED:
+
+ existing = hass.data[DOMAIN]["dashboards"].get(url_path)
+
+ if existing:
+ _LOGGER.warning(
+ "Cannot register panel at %s, it is already defined in %s",
+ url_path,
+ existing,
+ )
+ return
+
+ hass.data[DOMAIN]["dashboards"][url_path] = dashboard.LovelaceStorage(
+ hass, item
+ )
+
+ update = False
+ else:
+ hass.data[DOMAIN]["dashboards"][url_path].config = item
+ update = True
+
+ try:
+ _register_panel(hass, url_path, MODE_STORAGE, item, update)
+ except ValueError:
+ _LOGGER.warning("Failed to %s panel %s from storage", change_type, url_path)
+
+ # Process YAML dashboards
+ for url_path, dashboard_conf in hass.data[DOMAIN]["yaml_dashboards"].items():
+ # For now always mode=yaml
+ config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf)
+ hass.data[DOMAIN]["dashboards"][url_path] = config
+
+ try:
+ _register_panel(hass, url_path, MODE_YAML, dashboard_conf, False)
+ except ValueError:
+ _LOGGER.warning("Panel url path %s is not unique", url_path)
+
+ # Process storage dashboards
+ dashboards_collection = dashboard.DashboardsCollection(hass)
+
+ dashboards_collection.async_add_listener(storage_dashboard_changed)
+ await dashboards_collection.async_load()
+
+ collection.StorageCollectionWebsocket(
+ dashboards_collection,
+ "lovelace/dashboards",
+ "dashboard",
+ STORAGE_DASHBOARD_CREATE_FIELDS,
+ STORAGE_DASHBOARD_UPDATE_FIELDS,
+ ).async_setup(hass, create_list=False)
+
return True
-class LovelaceStorage:
- """Class to handle Storage based Lovelace config."""
-
- def __init__(self, hass):
- """Initialize Lovelace config based on storage helper."""
- self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
- self._data = None
- self._hass = hass
-
- async def async_get_info(self):
- """Return the YAML storage mode."""
- if self._data is None:
- await self._load()
-
- if self._data["config"] is None:
- return {"mode": "auto-gen"}
-
- return _config_info("storage", self._data["config"])
-
- async def async_load(self, force):
- """Load config."""
- if self._hass.config.safe_mode:
- raise ConfigNotFound
-
- if self._data is None:
- await self._load()
-
- config = self._data["config"]
-
- if config is None:
- raise ConfigNotFound
-
- return config
-
- async def async_save(self, config):
- """Save config."""
- if self._hass.config.safe_mode:
- raise HomeAssistantError("Deleting not supported in safe mode")
-
- if self._data is None:
- await self._load()
- self._data["config"] = config
- self._hass.bus.async_fire(EVENT_LOVELACE_UPDATED)
- await self._store.async_save(self._data)
-
- async def async_delete(self):
- """Delete config."""
- if self._hass.config.safe_mode:
- raise HomeAssistantError("Deleting not supported in safe mode")
-
- await self.async_save(None)
-
- async def _load(self):
- """Load the config."""
- data = await self._store.async_load()
- self._data = data if data else {"config": None}
-
-
-class LovelaceYAML:
- """Class to handle YAML-based Lovelace config."""
-
- def __init__(self, hass):
- """Initialize the YAML config."""
- self.hass = hass
- self._cache = None
-
- async def async_get_info(self):
- """Return the YAML storage mode."""
+async def create_yaml_resource_col(hass, yaml_resources):
+ """Create yaml resources collection."""
+ if yaml_resources is None:
+ default_config = dashboard.LovelaceYAML(hass, None, None)
try:
- config = await self.async_load(False)
- except ConfigNotFound:
- return {
- "mode": "yaml",
- "error": "{} not found".format(
- self.hass.config.path(LOVELACE_CONFIG_FILE)
- ),
- }
-
- return _config_info("yaml", config)
-
- async def async_load(self, force):
- """Load config."""
- is_updated, config = await self.hass.async_add_executor_job(
- self._load_config, force
- )
- if is_updated:
- self.hass.bus.async_fire(EVENT_LOVELACE_UPDATED)
- return config
-
- def _load_config(self, force):
- """Load the actual config."""
- fname = self.hass.config.path(LOVELACE_CONFIG_FILE)
- # Check for a cached version of the config
- if not force and self._cache is not None:
- config, last_update = self._cache
- modtime = os.path.getmtime(fname)
- if config and last_update > modtime:
- return False, config
-
- is_updated = self._cache is not None
-
- try:
- config = load_yaml(fname)
- except FileNotFoundError:
- raise ConfigNotFound from None
-
- self._cache = (config, time.time())
- return is_updated, config
-
- async def async_save(self, config):
- """Save config."""
- raise HomeAssistantError("Not supported")
-
- async def async_delete(self):
- """Delete config."""
- raise HomeAssistantError("Not supported")
-
-
-def handle_yaml_errors(func):
- """Handle error with WebSocket calls."""
-
- @wraps(func)
- async def send_with_error_handling(hass, connection, msg):
- error = None
- try:
- result = await func(hass, connection, msg)
- except ConfigNotFound:
- error = "config_not_found", "No config found."
- except HomeAssistantError as err:
- error = "error", str(err)
-
- if error is not None:
- connection.send_error(msg["id"], *error)
- return
-
- if msg is not None:
- await connection.send_big_result(msg["id"], result)
+ ll_conf = await default_config.async_load(False)
+ except HomeAssistantError:
+ pass
else:
- connection.send_result(msg["id"], result)
+ if CONF_RESOURCES in ll_conf:
+ _LOGGER.warning(
+ "Resources need to be specified in your configuration.yaml. Please see the docs."
+ )
+ yaml_resources = ll_conf[CONF_RESOURCES]
- return send_with_error_handling
-
-
-@websocket_api.async_response
-@websocket_api.websocket_command(
- {"type": "lovelace/config", vol.Optional("force", default=False): bool}
-)
-@handle_yaml_errors
-async def websocket_lovelace_config(hass, connection, msg):
- """Send Lovelace UI config over WebSocket configuration."""
- return await hass.data[DOMAIN].async_load(msg["force"])
-
-
-@websocket_api.async_response
-@websocket_api.websocket_command(
- {"type": "lovelace/config/save", "config": vol.Any(str, dict)}
-)
-@handle_yaml_errors
-async def websocket_lovelace_save_config(hass, connection, msg):
- """Save Lovelace UI configuration."""
- await hass.data[DOMAIN].async_save(msg["config"])
-
-
-@websocket_api.async_response
-@websocket_api.websocket_command({"type": "lovelace/config/delete"})
-@handle_yaml_errors
-async def websocket_lovelace_delete_config(hass, connection, msg):
- """Delete Lovelace UI configuration."""
- await hass.data[DOMAIN].async_delete()
+ return resources.ResourceYAMLCollection(yaml_resources or [])
async def system_health_info(hass):
"""Get info for the info page."""
- return await hass.data[DOMAIN].async_get_info()
+ return await hass.data[DOMAIN]["dashboards"][None].async_get_info()
-def _config_info(mode, config):
- """Generate info about the config."""
- return {
- "mode": mode,
- "resources": len(config.get("resources", [])),
- "views": len(config.get("views", [])),
+@callback
+def _register_panel(hass, url_path, mode, config, update):
+ """Register a panel."""
+ kwargs = {
+ "frontend_url_path": url_path,
+ "require_admin": config[CONF_REQUIRE_ADMIN],
+ "config": {"mode": mode},
+ "update": update,
}
+
+ if config[CONF_SHOW_IN_SIDEBAR]:
+ kwargs["sidebar_title"] = config[CONF_TITLE]
+ kwargs["sidebar_icon"] = config.get(CONF_ICON, DEFAULT_ICON)
+
+ frontend.async_register_built_in_panel(hass, DOMAIN, **kwargs)
diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py
new file mode 100644
index 00000000000..a093c672dd6
--- /dev/null
+++ b/homeassistant/components/lovelace/const.py
@@ -0,0 +1,92 @@
+"""Constants for Lovelace."""
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_ICON, CONF_TYPE, CONF_URL
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
+from homeassistant.util import slugify
+
+DOMAIN = "lovelace"
+EVENT_LOVELACE_UPDATED = "lovelace_updated"
+
+DEFAULT_ICON = "hass:view-dashboard"
+
+CONF_MODE = "mode"
+MODE_YAML = "yaml"
+MODE_STORAGE = "storage"
+
+LOVELACE_CONFIG_FILE = "ui-lovelace.yaml"
+CONF_RESOURCES = "resources"
+CONF_URL_PATH = "url_path"
+CONF_RESOURCE_TYPE_WS = "res_type"
+
+RESOURCE_TYPES = ["js", "css", "module", "html"]
+
+RESOURCE_FIELDS = {
+ CONF_TYPE: vol.In(RESOURCE_TYPES),
+ CONF_URL: cv.string,
+}
+
+RESOURCE_SCHEMA = vol.Schema(RESOURCE_FIELDS)
+
+RESOURCE_CREATE_FIELDS = {
+ vol.Required(CONF_RESOURCE_TYPE_WS): vol.In(RESOURCE_TYPES),
+ vol.Required(CONF_URL): cv.string,
+}
+
+RESOURCE_UPDATE_FIELDS = {
+ vol.Optional(CONF_RESOURCE_TYPE_WS): vol.In(RESOURCE_TYPES),
+ vol.Optional(CONF_URL): cv.string,
+}
+
+SERVICE_RELOAD_RESOURCES = "reload_resources"
+RESOURCE_RELOAD_SERVICE_SCHEMA = vol.Schema({})
+
+CONF_TITLE = "title"
+CONF_REQUIRE_ADMIN = "require_admin"
+CONF_SHOW_IN_SIDEBAR = "show_in_sidebar"
+
+DASHBOARD_BASE_CREATE_FIELDS = {
+ vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean,
+ vol.Optional(CONF_ICON): cv.icon,
+ vol.Required(CONF_TITLE): cv.string,
+ vol.Optional(CONF_SHOW_IN_SIDEBAR, default=True): cv.boolean,
+}
+
+
+DASHBOARD_BASE_UPDATE_FIELDS = {
+ vol.Optional(CONF_REQUIRE_ADMIN): cv.boolean,
+ vol.Optional(CONF_ICON): vol.Any(cv.icon, None),
+ vol.Optional(CONF_TITLE): cv.string,
+ vol.Optional(CONF_SHOW_IN_SIDEBAR): cv.boolean,
+}
+
+
+STORAGE_DASHBOARD_CREATE_FIELDS = {
+ **DASHBOARD_BASE_CREATE_FIELDS,
+ vol.Required(CONF_URL_PATH): cv.string,
+ # For now we write "storage" as all modes.
+ # In future we can adjust this to be other modes.
+ vol.Optional(CONF_MODE, default=MODE_STORAGE): MODE_STORAGE,
+}
+
+STORAGE_DASHBOARD_UPDATE_FIELDS = DASHBOARD_BASE_UPDATE_FIELDS
+
+
+def url_slug(value: Any) -> str:
+ """Validate value is a valid url slug."""
+ if value is None:
+ raise vol.Invalid("Slug should not be None")
+ if "-" not in value:
+ raise vol.Invalid("Url path needs to contain a hyphen (-)")
+ str_value = str(value)
+ slg = slugify(str_value, separator="-")
+ if str_value == slg:
+ return str_value
+ raise vol.Invalid(f"invalid slug {value} (try {slg})")
+
+
+class ConfigNotFound(HomeAssistantError):
+ """When no config available."""
diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py
new file mode 100644
index 00000000000..cdb104a150b
--- /dev/null
+++ b/homeassistant/components/lovelace/dashboard.py
@@ -0,0 +1,276 @@
+"""Lovelace dashboard support."""
+from abc import ABC, abstractmethod
+import logging
+import os
+import time
+from typing import Optional, cast
+
+import voluptuous as vol
+
+from homeassistant.components.frontend import DATA_PANELS
+from homeassistant.const import CONF_FILENAME
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import collection, storage
+from homeassistant.util.yaml import load_yaml
+
+from .const import (
+ CONF_ICON,
+ CONF_URL_PATH,
+ DOMAIN,
+ EVENT_LOVELACE_UPDATED,
+ LOVELACE_CONFIG_FILE,
+ MODE_STORAGE,
+ MODE_YAML,
+ STORAGE_DASHBOARD_CREATE_FIELDS,
+ STORAGE_DASHBOARD_UPDATE_FIELDS,
+ ConfigNotFound,
+)
+
+CONFIG_STORAGE_KEY_DEFAULT = DOMAIN
+CONFIG_STORAGE_KEY = "lovelace.{}"
+CONFIG_STORAGE_VERSION = 1
+DASHBOARDS_STORAGE_KEY = f"{DOMAIN}_dashboards"
+DASHBOARDS_STORAGE_VERSION = 1
+_LOGGER = logging.getLogger(__name__)
+
+
+class LovelaceConfig(ABC):
+ """Base class for Lovelace config."""
+
+ def __init__(self, hass, url_path, config):
+ """Initialize Lovelace config."""
+ self.hass = hass
+ if config:
+ self.config = {**config, CONF_URL_PATH: url_path}
+ else:
+ self.config = None
+
+ @property
+ def url_path(self) -> str:
+ """Return url path."""
+ return self.config[CONF_URL_PATH] if self.config else None
+
+ @property
+ @abstractmethod
+ def mode(self) -> str:
+ """Return mode of the lovelace config."""
+
+ @abstractmethod
+ async def async_get_info(self):
+ """Return the config info."""
+
+ @abstractmethod
+ async def async_load(self, force):
+ """Load config."""
+
+ async def async_save(self, config):
+ """Save config."""
+ raise HomeAssistantError("Not supported")
+
+ async def async_delete(self):
+ """Delete config."""
+ raise HomeAssistantError("Not supported")
+
+ @callback
+ def _config_updated(self):
+ """Fire config updated event."""
+ self.hass.bus.async_fire(EVENT_LOVELACE_UPDATED, {"url_path": self.url_path})
+
+
+class LovelaceStorage(LovelaceConfig):
+ """Class to handle Storage based Lovelace config."""
+
+ def __init__(self, hass, config):
+ """Initialize Lovelace config based on storage helper."""
+ if config is None:
+ url_path = None
+ storage_key = CONFIG_STORAGE_KEY_DEFAULT
+ else:
+ url_path = config[CONF_URL_PATH]
+ storage_key = CONFIG_STORAGE_KEY.format(config["id"])
+
+ super().__init__(hass, url_path, config)
+
+ self._store = storage.Store(hass, CONFIG_STORAGE_VERSION, storage_key)
+ self._data = None
+
+ @property
+ def mode(self) -> str:
+ """Return mode of the lovelace config."""
+ return MODE_STORAGE
+
+ async def async_get_info(self):
+ """Return the YAML storage mode."""
+ if self._data is None:
+ await self._load()
+
+ if self._data["config"] is None:
+ return {"mode": "auto-gen"}
+
+ return _config_info(self.mode, self._data["config"])
+
+ async def async_load(self, force):
+ """Load config."""
+ if self.hass.config.safe_mode:
+ raise ConfigNotFound
+
+ if self._data is None:
+ await self._load()
+
+ config = self._data["config"]
+
+ if config is None:
+ raise ConfigNotFound
+
+ return config
+
+ async def async_save(self, config):
+ """Save config."""
+ if self.hass.config.safe_mode:
+ raise HomeAssistantError("Saving not supported in safe mode")
+
+ if self._data is None:
+ await self._load()
+ self._data["config"] = config
+ self._config_updated()
+ await self._store.async_save(self._data)
+
+ async def async_delete(self):
+ """Delete config."""
+ if self.hass.config.safe_mode:
+ raise HomeAssistantError("Deleting not supported in safe mode")
+
+ await self._store.async_remove()
+ self._data = None
+ self._config_updated()
+
+ async def _load(self):
+ """Load the config."""
+ data = await self._store.async_load()
+ self._data = data if data else {"config": None}
+
+
+class LovelaceYAML(LovelaceConfig):
+ """Class to handle YAML-based Lovelace config."""
+
+ def __init__(self, hass, url_path, config):
+ """Initialize the YAML config."""
+ super().__init__(hass, url_path, config)
+
+ self.path = hass.config.path(
+ config[CONF_FILENAME] if config else LOVELACE_CONFIG_FILE
+ )
+ self._cache = None
+
+ @property
+ def mode(self) -> str:
+ """Return mode of the lovelace config."""
+ return MODE_YAML
+
+ async def async_get_info(self):
+ """Return the YAML storage mode."""
+ try:
+ config = await self.async_load(False)
+ except ConfigNotFound:
+ return {
+ "mode": self.mode,
+ "error": f"{self.path} not found",
+ }
+
+ return _config_info(self.mode, config)
+
+ async def async_load(self, force):
+ """Load config."""
+ is_updated, config = await self.hass.async_add_executor_job(
+ self._load_config, force
+ )
+ if is_updated:
+ self._config_updated()
+ return config
+
+ def _load_config(self, force):
+ """Load the actual config."""
+ # Check for a cached version of the config
+ if not force and self._cache is not None:
+ config, last_update = self._cache
+ modtime = os.path.getmtime(self.path)
+ if config and last_update > modtime:
+ return False, config
+
+ is_updated = self._cache is not None
+
+ try:
+ config = load_yaml(self.path)
+ except FileNotFoundError:
+ raise ConfigNotFound from None
+
+ self._cache = (config, time.time())
+ return is_updated, config
+
+
+def _config_info(mode, config):
+ """Generate info about the config."""
+ return {
+ "mode": mode,
+ "resources": len(config.get("resources", [])),
+ "views": len(config.get("views", [])),
+ }
+
+
+class DashboardsCollection(collection.StorageCollection):
+ """Collection of dashboards."""
+
+ CREATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_CREATE_FIELDS)
+ UPDATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_UPDATE_FIELDS)
+
+ def __init__(self, hass):
+ """Initialize the dashboards collection."""
+ super().__init__(
+ storage.Store(hass, DASHBOARDS_STORAGE_VERSION, DASHBOARDS_STORAGE_KEY),
+ _LOGGER,
+ )
+
+ async def _async_load_data(self) -> Optional[dict]:
+ """Load the data."""
+ data = await self.store.async_load()
+
+ if data is None:
+ return cast(Optional[dict], data)
+
+ updated = False
+
+ for item in data["items"] or []:
+ if "-" not in item[CONF_URL_PATH]:
+ updated = True
+ item[CONF_URL_PATH] = f"lovelace-{item[CONF_URL_PATH]}"
+
+ if updated:
+ await self.store.async_save(data)
+
+ return cast(Optional[dict], data)
+
+ async def _process_create_data(self, data: dict) -> dict:
+ """Validate the config is valid."""
+ if "-" not in data[CONF_URL_PATH]:
+ raise vol.Invalid("Url path needs to contain a hyphen (-)")
+
+ if data[CONF_URL_PATH] in self.hass.data[DATA_PANELS]:
+ raise vol.Invalid("Panel url path needs to be unique")
+
+ return self.CREATE_SCHEMA(data)
+
+ @callback
+ def _get_suggested_id(self, info: dict) -> str:
+ """Suggest an ID based on the config."""
+ return info[CONF_URL_PATH]
+
+ async def _update_data(self, data: dict, update_data: dict) -> dict:
+ """Return a new updated data object."""
+ update_data = self.UPDATE_SCHEMA(update_data)
+ updated = {**data, **update_data}
+
+ if CONF_ICON in updated and updated[CONF_ICON] is None:
+ updated.pop(CONF_ICON)
+
+ return updated
diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py
new file mode 100644
index 00000000000..57acaa487bd
--- /dev/null
+++ b/homeassistant/components/lovelace/resources.py
@@ -0,0 +1,116 @@
+"""Lovelace resources support."""
+import logging
+from typing import List, Optional, cast
+import uuid
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_TYPE
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import collection, storage
+
+from .const import (
+ CONF_RESOURCE_TYPE_WS,
+ CONF_RESOURCES,
+ DOMAIN,
+ RESOURCE_CREATE_FIELDS,
+ RESOURCE_SCHEMA,
+ RESOURCE_UPDATE_FIELDS,
+)
+from .dashboard import LovelaceConfig
+
+RESOURCE_STORAGE_KEY = f"{DOMAIN}_resources"
+RESOURCES_STORAGE_VERSION = 1
+_LOGGER = logging.getLogger(__name__)
+
+
+class ResourceYAMLCollection:
+ """Collection representing static YAML."""
+
+ loaded = True
+
+ def __init__(self, data):
+ """Initialize a resource YAML collection."""
+ self.data = data
+
+ @callback
+ def async_items(self) -> List[dict]:
+ """Return list of items in collection."""
+ return self.data
+
+
+class ResourceStorageCollection(collection.StorageCollection):
+ """Collection to store resources."""
+
+ loaded = False
+ CREATE_SCHEMA = vol.Schema(RESOURCE_CREATE_FIELDS)
+ UPDATE_SCHEMA = vol.Schema(RESOURCE_UPDATE_FIELDS)
+
+ def __init__(self, hass: HomeAssistant, ll_config: LovelaceConfig):
+ """Initialize the storage collection."""
+ super().__init__(
+ storage.Store(hass, RESOURCES_STORAGE_VERSION, RESOURCE_STORAGE_KEY),
+ _LOGGER,
+ )
+ self.ll_config = ll_config
+
+ async def _async_load_data(self) -> Optional[dict]:
+ """Load the data."""
+ data = await self.store.async_load()
+
+ if data is not None:
+ return cast(Optional[dict], data)
+
+ # Import it from config.
+ try:
+ conf = await self.ll_config.async_load(False)
+ except HomeAssistantError:
+ return None
+
+ if CONF_RESOURCES not in conf:
+ return None
+
+ # Remove it from config and save both resources + config
+ data = conf[CONF_RESOURCES]
+
+ try:
+ vol.Schema([RESOURCE_SCHEMA])(data)
+ except vol.Invalid as err:
+ _LOGGER.warning("Resource import failed. Data invalid: %s", err)
+ return None
+
+ conf.pop(CONF_RESOURCES)
+
+ for item in data:
+ item[collection.CONF_ID] = uuid.uuid4().hex
+
+ data = {"items": data}
+
+ await self.store.async_save(data)
+ await self.ll_config.async_save(conf)
+
+ return data
+
+ async def _process_create_data(self, data: dict) -> dict:
+ """Validate the config is valid."""
+ data = self.CREATE_SCHEMA(data)
+ data[CONF_TYPE] = data.pop(CONF_RESOURCE_TYPE_WS)
+ return data
+
+ @callback
+ def _get_suggested_id(self, info: dict) -> str:
+ """Return unique ID."""
+ return uuid.uuid4().hex
+
+ async def _update_data(self, data: dict, update_data: dict) -> dict:
+ """Return a new updated data object."""
+ if not self.loaded:
+ await self.async_load()
+ self.loaded = True
+
+ update_data = self.UPDATE_SCHEMA(update_data)
+ if CONF_RESOURCE_TYPE_WS in update_data:
+ update_data[CONF_TYPE] = update_data.pop(CONF_RESOURCE_TYPE_WS)
+
+ return {**data, **update_data}
diff --git a/homeassistant/components/lovelace/services.yaml b/homeassistant/components/lovelace/services.yaml
new file mode 100644
index 00000000000..1147f287e59
--- /dev/null
+++ b/homeassistant/components/lovelace/services.yaml
@@ -0,0 +1,4 @@
+# Describes the format for available lovelace services
+
+reload_resources:
+ description: Reload Lovelace resources from yaml configuration.
diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py
new file mode 100644
index 00000000000..a4e67fda929
--- /dev/null
+++ b/homeassistant/components/lovelace/websocket.py
@@ -0,0 +1,113 @@
+"""Websocket API for Lovelace."""
+from functools import wraps
+
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
+
+from .const import CONF_URL_PATH, DOMAIN, ConfigNotFound
+
+
+def _handle_errors(func):
+ """Handle error with WebSocket calls."""
+
+ @wraps(func)
+ async def send_with_error_handling(hass, connection, msg):
+ url_path = msg.get(CONF_URL_PATH)
+ config = hass.data[DOMAIN]["dashboards"].get(url_path)
+
+ if config is None:
+ connection.send_error(
+ msg["id"], "config_not_found", f"Unknown config specified: {url_path}"
+ )
+ return
+
+ error = None
+ try:
+ result = await func(hass, connection, msg, config)
+ except ConfigNotFound:
+ error = "config_not_found", "No config found."
+ except HomeAssistantError as err:
+ error = "error", str(err)
+
+ if error is not None:
+ connection.send_error(msg["id"], *error)
+ return
+
+ if msg is not None:
+ await connection.send_big_result(msg["id"], result)
+ else:
+ connection.send_result(msg["id"], result)
+
+ return send_with_error_handling
+
+
+@websocket_api.async_response
+@websocket_api.websocket_command({"type": "lovelace/resources"})
+async def websocket_lovelace_resources(hass, connection, msg):
+ """Send Lovelace UI resources over WebSocket configuration."""
+ resources = hass.data[DOMAIN]["resources"]
+
+ if not resources.loaded:
+ await resources.async_load()
+ resources.loaded = True
+
+ connection.send_result(msg["id"], resources.async_items())
+
+
+@websocket_api.async_response
+@websocket_api.websocket_command(
+ {
+ "type": "lovelace/config",
+ vol.Optional("force", default=False): bool,
+ vol.Optional(CONF_URL_PATH): vol.Any(None, cv.string),
+ }
+)
+@_handle_errors
+async def websocket_lovelace_config(hass, connection, msg, config):
+ """Send Lovelace UI config over WebSocket configuration."""
+ return await config.async_load(msg["force"])
+
+
+@websocket_api.async_response
+@websocket_api.websocket_command(
+ {
+ "type": "lovelace/config/save",
+ "config": vol.Any(str, dict),
+ vol.Optional(CONF_URL_PATH): vol.Any(None, cv.string),
+ }
+)
+@_handle_errors
+async def websocket_lovelace_save_config(hass, connection, msg, config):
+ """Save Lovelace UI configuration."""
+ await config.async_save(msg["config"])
+
+
+@websocket_api.async_response
+@websocket_api.websocket_command(
+ {
+ "type": "lovelace/config/delete",
+ vol.Optional(CONF_URL_PATH): vol.Any(None, cv.string),
+ }
+)
+@_handle_errors
+async def websocket_lovelace_delete_config(hass, connection, msg, config):
+ """Delete Lovelace UI configuration."""
+ await config.async_delete()
+
+
+@websocket_api.websocket_command({"type": "lovelace/dashboards/list"})
+@callback
+def websocket_lovelace_dashboards(hass, connection, msg):
+ """Delete Lovelace UI configuration."""
+ connection.send_result(
+ msg["id"],
+ [
+ dashboard.config
+ for dashboard in hass.data[DOMAIN]["dashboards"].values()
+ if dashboard.config
+ ],
+ )
diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py
index 4daadcd9c94..f7ed2d72f16 100644
--- a/homeassistant/components/luftdaten/__init__.py
+++ b/homeassistant/components/luftdaten/__init__.py
@@ -7,11 +7,13 @@ import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONF_MONITORED_CONDITIONS,
CONF_SCAN_INTERVAL,
CONF_SENSORS,
CONF_SHOW_ON_MAP,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady
@@ -39,18 +41,20 @@ SENSOR_TEMPERATURE = "temperature"
TOPIC_UPDATE = f"{DOMAIN}_data_update"
-VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m3"
-
SENSORS = {
SENSOR_TEMPERATURE: ["Temperature", "mdi:thermometer", TEMP_CELSIUS],
- SENSOR_HUMIDITY: ["Humidity", "mdi:water-percent", "%"],
+ SENSOR_HUMIDITY: ["Humidity", "mdi:water-percent", UNIT_PERCENTAGE],
SENSOR_PRESSURE: ["Pressure", "mdi:arrow-down-bold", "Pa"],
SENSOR_PRESSURE_AT_SEALEVEL: ["Pressure at sealevel", "mdi:download", "Pa"],
- SENSOR_PM10: ["PM10", "mdi:thought-bubble", VOLUME_MICROGRAMS_PER_CUBIC_METER],
+ SENSOR_PM10: [
+ "PM10",
+ "mdi:thought-bubble",
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ ],
SENSOR_PM2_5: [
"PM2.5",
"mdi:thought-bubble-outline",
- VOLUME_MICROGRAMS_PER_CUBIC_METER,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
],
}
diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py
index 6fc48081adc..cfde5bba872 100644
--- a/homeassistant/components/luftdaten/sensor.py
+++ b/homeassistant/components/luftdaten/sensor.py
@@ -80,7 +80,7 @@ class LuftdatenSensor(Entity):
def unique_id(self) -> str:
"""Return a unique, friendly identifier for this entity."""
if self._data is not None:
- return "{0}_{1}".format(self._data["sensor_id"], self.sensor_type)
+ return f"{self._data['sensor_id']}_{self.sensor_type}"
@property
def device_state_attributes(self):
diff --git a/homeassistant/components/lupusec/__init__.py b/homeassistant/components/lupusec/__init__.py
index 60f3a192b07..3ae07bd8105 100644
--- a/homeassistant/components/lupusec/__init__.py
+++ b/homeassistant/components/lupusec/__init__.py
@@ -47,9 +47,7 @@ def setup(hass, config):
_LOGGER.error(ex)
hass.components.persistent_notification.create(
- "Error: {}
"
- "You will need to restart hass after fixing."
- "".format(ex),
+ f"Error: {ex}
You will need to restart hass after fixing.",
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py
index 3f625a7c28b..4e06c5df626 100644
--- a/homeassistant/components/lutron/scene.py
+++ b/homeassistant/components/lutron/scene.py
@@ -37,6 +37,4 @@ class LutronScene(LutronDevice, Scene):
@property
def name(self):
"""Return the name of the device."""
- return "{} {}: {}".format(
- self._area_name, self._keypad_name, self._lutron_device.name
- )
+ return f"{self._area_name} {self._keypad_name}: {self._lutron_device.name}"
diff --git a/homeassistant/components/lyft/sensor.py b/homeassistant/components/lyft/sensor.py
index 1b90d66398e..5e8555f857d 100644
--- a/homeassistant/components/lyft/sensor.py
+++ b/homeassistant/components/lyft/sensor.py
@@ -8,6 +8,7 @@ from lyft_rides.errors import APIError
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import TIME_MINUTES
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle
@@ -84,11 +85,11 @@ class LyftSensor(Entity):
self._product_id = product_id
self._product = product
self._sensortype = sensorType
- self._name = "{} {}".format(self._product["display_name"], self._sensortype)
+ self._name = f"{self._product['display_name']} {self._sensortype}"
if "lyft" not in self._name.lower():
self._name = f"Lyft{self._name}"
if self._sensortype == "time":
- self._unit_of_measurement = "min"
+ self._unit_of_measurement = TIME_MINUTES
elif self._sensortype == "price":
estimate = self._product["estimate"]
if estimate is not None:
diff --git a/homeassistant/components/magicseaweed/sensor.py b/homeassistant/components/magicseaweed/sensor.py
index 174ecf1882e..9364bee27b2 100644
--- a/homeassistant/components/magicseaweed/sensor.py
+++ b/homeassistant/components/magicseaweed/sensor.py
@@ -154,18 +154,12 @@ class MagicSeaweedSensor(Entity):
elif self.type == "max_breaking_swell":
self._state = forecast.swell_maxBreakingHeight
elif self.type == "swell_forecast":
- summary = "{} - {}".format(
- forecast.swell_minBreakingHeight, forecast.swell_maxBreakingHeight
- )
+ summary = f"{forecast.swell_minBreakingHeight} - {forecast.swell_maxBreakingHeight}"
self._state = summary
if self.hour is None:
for hour, data in self.data.hourly.items():
occurs = hour
- hr_summary = "{} - {} {}".format(
- data.swell_minBreakingHeight,
- data.swell_maxBreakingHeight,
- data.swell_unit,
- )
+ hr_summary = f"{data.swell_minBreakingHeight} - {data.swell_maxBreakingHeight} {data.swell_unit}"
self._attrs[occurs] = hr_summary
if self.type != "swell_forecast":
diff --git a/homeassistant/components/mailgun/notify.py b/homeassistant/components/mailgun/notify.py
index c2222cfd742..5ff57914be0 100644
--- a/homeassistant/components/mailgun/notify.py
+++ b/homeassistant/components/mailgun/notify.py
@@ -25,7 +25,6 @@ _LOGGER = logging.getLogger(__name__)
# Images to attach to notification
ATTR_IMAGES = "images"
-DEFAULT_SENDER = "hass@{domain}"
DEFAULT_SANDBOX = False
# pylint: disable=no-value-for-parameter
@@ -69,7 +68,7 @@ class MailgunNotificationService(BaseNotificationService):
_LOGGER.debug("Mailgun domain: %s", self._client.domain)
self._domain = self._client.domain
if not self._sender:
- self._sender = DEFAULT_SENDER.format(domain=self._domain)
+ self._sender = f"hass@{self._domain}"
def connection_is_valid(self):
"""Check whether the provided credentials are valid."""
diff --git a/homeassistant/components/maxcube/__init__.py b/homeassistant/components/maxcube/__init__.py
index 3e6ecbc948b..ffd156b5e00 100644
--- a/homeassistant/components/maxcube/__init__.py
+++ b/homeassistant/components/maxcube/__init__.py
@@ -65,9 +65,7 @@ def setup(hass, config):
except timeout as ex:
_LOGGER.error("Unable to connect to Max!Cube gateway: %s", str(ex))
hass.components.persistent_notification.create(
- "Error: {}
"
- "You will need to restart Home Assistant after fixing."
- "".format(ex),
+ f"Error: {ex}
You will need to restart Home Assistant after fixing.",
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
diff --git a/homeassistant/components/maxcube/binary_sensor.py b/homeassistant/components/maxcube/binary_sensor.py
index 639b670baa8..2670b61b456 100644
--- a/homeassistant/components/maxcube/binary_sensor.py
+++ b/homeassistant/components/maxcube/binary_sensor.py
@@ -14,7 +14,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
for handler in hass.data[DATA_KEY].values():
cube = handler.cube
for device in cube.devices:
- name = "{} {}".format(cube.room_by_id(device.room_id).name, device.name)
+ name = f"{cube.room_by_id(device.room_id).name} {device.name}"
# Only add Window Shutters
if cube.is_windowshutter(device):
diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py
index a6a63ddc0fd..e723853f629 100644
--- a/homeassistant/components/maxcube/climate.py
+++ b/homeassistant/components/maxcube/climate.py
@@ -34,7 +34,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
for handler in hass.data[DATA_KEY].values():
cube = handler.cube
for device in cube.devices:
- name = "{} {}".format(cube.room_by_id(device.room_id).name, device.name)
+ name = f"{cube.room_by_id(device.room_id).name} {device.name}"
if cube.is_thermostat(device) or cube.is_wallthermostat(device):
devices.append(MaxCubeClimate(handler, name, device.rf_address))
diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json
index 815a00c5223..631dc7675ca 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.02.16"],
+ "requirements": ["youtube_dl==2020.03.08"],
"dependencies": ["media_player"],
"codeowners": [],
"quality_scale": "internal"
diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py
index 8a31dbe6bdb..757dd00897d 100644
--- a/homeassistant/components/media_player/__init__.py
+++ b/homeassistant/components/media_player/__init__.py
@@ -104,7 +104,6 @@ _RND = SystemRandom()
ENTITY_ID_FORMAT = DOMAIN + ".{}"
-ENTITY_IMAGE_URL = "/api/media_player_proxy/{0}?token={1}&cache={2}"
CACHE_IMAGES = "images"
CACHE_MAXSIZE = "maxsize"
CACHE_LOCK = "lock"
@@ -757,12 +756,20 @@ class MediaPlayerDevice(Entity):
if self.media_image_remotely_accessible:
return self.media_image_url
+ return self.media_image_local
+
+ @property
+ def media_image_local(self):
+ """Return local url to media image."""
image_hash = self.media_image_hash
if image_hash is None:
return None
- return ENTITY_IMAGE_URL.format(self.entity_id, self.access_token, image_hash)
+ return (
+ f"/api/media_player_proxy/{self.entity_id}?"
+ f"token={self.access_token}&cache={image_hash}"
+ )
@property
def capability_attributes(self):
@@ -788,11 +795,15 @@ class MediaPlayerDevice(Entity):
if self.state == STATE_OFF:
return None
- state_attr = {
- attr: getattr(self, attr)
- for attr in ATTR_TO_PROPERTY
- if getattr(self, attr) is not None
- }
+ state_attr = {}
+
+ for attr in ATTR_TO_PROPERTY:
+ value = getattr(self, attr)
+ if value is not None:
+ state_attr[attr] = value
+
+ if self.media_image_remotely_accessible:
+ state_attr["entity_picture_local"] = self.media_image_local
return state_attr
@@ -863,12 +874,6 @@ class MediaPlayerImageView(HomeAssistantView):
if not authenticated:
return web.Response(status=401)
- if player.media_image_remotely_accessible:
- url = player.media_image_url
- if url is not None:
- return web.Response(status=302, headers={"location": url})
- return web.Response(status=500)
-
data, content_type = await player.async_get_media_image()
if data is None:
@@ -895,6 +900,10 @@ async def websocket_handle_thumbnail(hass, connection, msg):
)
return
+ _LOGGER.warning(
+ "The websocket command media_player_thumbnail is deprecated. Use /api/media_player_proxy instead."
+ )
+
data, content_type = await player.async_get_media_image()
if data is None:
diff --git a/homeassistant/components/mediaroom/media_player.py b/homeassistant/components/mediaroom/media_player.py
index 539138783ee..28d20d0db4b 100644
--- a/homeassistant/components/mediaroom/media_player.py
+++ b/homeassistant/components/mediaroom/media_player.py
@@ -147,7 +147,7 @@ class MediaroomDevice(MediaPlayerDevice):
self._channel = None
self._optimistic = optimistic
self._state = STATE_PLAYING if optimistic else STATE_STANDBY
- self._name = "Mediaroom {}".format(device_id if device_id else host)
+ self._name = f"Mediaroom {device_id if device_id else host}"
self._available = True
if device_id:
self._unique_id = device_id
diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py
index ef932f36aa4..0e81d6101b3 100644
--- a/homeassistant/components/melcloud/__init__.py
+++ b/homeassistant/components/melcloud/__init__.py
@@ -13,6 +13,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_TOKEN, CONF_USERNAME
from homeassistant.exceptions import ConfigEntryNotReady
import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util import Throttle
@@ -22,7 +23,7 @@ _LOGGER = logging.getLogger(__name__)
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
-PLATFORMS = ["climate", "sensor"]
+PLATFORMS = ["climate", "sensor", "water_heater"]
CONF_LANGUAGE = "language"
CONFIG_SCHEMA = vol.Schema(
@@ -128,6 +129,7 @@ class MelCloudDevice:
def device_info(self):
"""Return a device description for device registry."""
_device_info = {
+ "connections": {(CONNECTION_NETWORK_MAC, self.device.mac)},
"identifiers": {(DOMAIN, f"{self.device.mac}-{self.device.serial}")},
"manufacturer": "Mitsubishi Electric",
"name": self.name,
diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py
index 95cb1489f45..c661b1a59ad 100644
--- a/homeassistant/components/melcloud/climate.py
+++ b/homeassistant/components/melcloud/climate.py
@@ -1,14 +1,26 @@
"""Platform for climate integration."""
from datetime import timedelta
import logging
-from typing import List, Optional
+from typing import Any, Dict, List, Optional
-from pymelcloud import DEVICE_TYPE_ATA
+from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW, AtaDevice, AtwDevice
+import pymelcloud.ata_device as ata
+import pymelcloud.atw_device as atw
+from pymelcloud.atw_device import (
+ PROPERTY_ZONE_1_OPERATION_MODE,
+ PROPERTY_ZONE_2_OPERATION_MODE,
+ Zone,
+)
from homeassistant.components.climate import ClimateDevice
from homeassistant.components.climate.const import (
DEFAULT_MAX_TEMP,
DEFAULT_MIN_TEMP,
+ HVAC_MODE_COOL,
+ HVAC_MODE_DRY,
+ HVAC_MODE_FAN_ONLY,
+ HVAC_MODE_HEAT,
+ HVAC_MODE_HEAT_COOL,
HVAC_MODE_OFF,
SUPPORT_FAN_MODE,
SUPPORT_TARGET_TEMPERATURE,
@@ -19,51 +31,90 @@ from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util.temperature import convert as convert_temperature
from . import MelCloudDevice
-from .const import DOMAIN, HVAC_MODE_LOOKUP, HVAC_MODE_REVERSE_LOOKUP, TEMP_UNIT_LOOKUP
+from .const import ATTR_STATUS, DOMAIN, TEMP_UNIT_LOOKUP
SCAN_INTERVAL = timedelta(seconds=60)
_LOGGER = logging.getLogger(__name__)
+ATA_HVAC_MODE_LOOKUP = {
+ ata.OPERATION_MODE_HEAT: HVAC_MODE_HEAT,
+ ata.OPERATION_MODE_DRY: HVAC_MODE_DRY,
+ ata.OPERATION_MODE_COOL: HVAC_MODE_COOL,
+ ata.OPERATION_MODE_FAN_ONLY: HVAC_MODE_FAN_ONLY,
+ ata.OPERATION_MODE_HEAT_COOL: HVAC_MODE_HEAT_COOL,
+}
+ATA_HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in ATA_HVAC_MODE_LOOKUP.items()}
+
+
+ATW_ZONE_HVAC_MODE_LOOKUP = {
+ atw.ZONE_OPERATION_MODE_HEAT: HVAC_MODE_HEAT,
+ atw.ZONE_OPERATION_MODE_COOL: HVAC_MODE_COOL,
+}
+ATW_ZONE_HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in ATW_ZONE_HVAC_MODE_LOOKUP.items()}
+
+
async def async_setup_entry(
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
):
"""Set up MelCloud device climate based on config_entry."""
mel_devices = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
- [AtaDeviceClimate(mel_device) for mel_device in mel_devices[DEVICE_TYPE_ATA]],
+ [
+ AtaDeviceClimate(mel_device, mel_device.device)
+ for mel_device in mel_devices[DEVICE_TYPE_ATA]
+ ]
+ + [
+ AtwDeviceZoneClimate(mel_device, mel_device.device, zone)
+ for mel_device in mel_devices[DEVICE_TYPE_ATW]
+ for zone in mel_device.device.zones
+ ],
True,
)
-class AtaDeviceClimate(ClimateDevice):
- """Air-to-Air climate device."""
+class MelCloudClimate(ClimateDevice):
+ """Base climate device."""
def __init__(self, device: MelCloudDevice):
"""Initialize the climate."""
- self._api = device
- self._device = self._api.device
+ self.api = device
+ self._base_device = self.api.device
self._name = device.name
- @property
- def unique_id(self) -> Optional[str]:
- """Return a unique ID."""
- return f"{self._device.serial}-{self._device.mac}"
-
- @property
- def name(self):
- """Return the display name of this light."""
- return self._name
-
async def async_update(self):
"""Update state from MELCloud."""
- await self._api.async_update()
+ await self.api.async_update()
@property
def device_info(self):
"""Return a device description for device registry."""
- return self._api.device_info
+ return self.api.device_info
+
+ @property
+ def target_temperature_step(self) -> Optional[float]:
+ """Return the supported step of target temperature."""
+ return self._base_device.temperature_increment
+
+
+class AtaDeviceClimate(MelCloudClimate):
+ """Air-to-Air climate device."""
+
+ def __init__(self, device: MelCloudDevice, ata_device: AtaDevice) -> None:
+ """Initialize the climate."""
+ super().__init__(device)
+ self._device = ata_device
+
+ @property
+ def unique_id(self) -> Optional[str]:
+ """Return a unique ID."""
+ return f"{self.api.device.serial}-{self.api.device.mac}"
+
+ @property
+ def name(self):
+ """Return the display name of this entity."""
+ return self._name
@property
def temperature_unit(self) -> str:
@@ -76,7 +127,7 @@ class AtaDeviceClimate(ClimateDevice):
mode = self._device.operation_mode
if not self._device.power or mode is None:
return HVAC_MODE_OFF
- return HVAC_MODE_LOOKUP.get(mode)
+ return ATA_HVAC_MODE_LOOKUP.get(mode)
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
"""Set new target hvac mode."""
@@ -84,7 +135,7 @@ class AtaDeviceClimate(ClimateDevice):
await self._device.set({"power": False})
return
- operation_mode = HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode)
+ operation_mode = ATA_HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode)
if operation_mode is None:
raise ValueError(f"Invalid hvac_mode [{hvac_mode}]")
@@ -97,7 +148,7 @@ class AtaDeviceClimate(ClimateDevice):
def hvac_modes(self) -> List[str]:
"""Return the list of available hvac operation modes."""
return [HVAC_MODE_OFF] + [
- HVAC_MODE_LOOKUP.get(mode) for mode in self._device.operation_modes
+ ATA_HVAC_MODE_LOOKUP.get(mode) for mode in self._device.operation_modes
]
@property
@@ -116,11 +167,6 @@ class AtaDeviceClimate(ClimateDevice):
{"target_temperature": kwargs.get("temperature", self.target_temperature)}
)
- @property
- def target_temperature_step(self) -> Optional[float]:
- """Return the supported step of target temperature."""
- return self._device.target_temperature_step
-
@property
def fan_mode(self) -> Optional[str]:
"""Return the fan setting."""
@@ -135,6 +181,11 @@ class AtaDeviceClimate(ClimateDevice):
"""Return the list of available fan modes."""
return self._device.fan_speeds
+ @property
+ def supported_features(self) -> int:
+ """Return the list of supported features."""
+ return SUPPORT_FAN_MODE | SUPPORT_TARGET_TEMPERATURE
+
async def async_turn_on(self) -> None:
"""Turn the entity on."""
await self._device.set({"power": True})
@@ -143,11 +194,6 @@ class AtaDeviceClimate(ClimateDevice):
"""Turn the entity off."""
await self._device.set({"power": False})
- @property
- def supported_features(self) -> int:
- """Return the list of supported features."""
- return SUPPORT_FAN_MODE | SUPPORT_TARGET_TEMPERATURE
-
@property
def min_temp(self) -> float:
"""Return the minimum temperature."""
@@ -169,3 +215,108 @@ class AtaDeviceClimate(ClimateDevice):
return convert_temperature(
DEFAULT_MAX_TEMP, TEMP_CELSIUS, self.temperature_unit
)
+
+
+class AtwDeviceZoneClimate(MelCloudClimate):
+ """Air-to-Water zone climate device."""
+
+ def __init__(
+ self, device: MelCloudDevice, atw_device: AtwDevice, atw_zone: Zone
+ ) -> None:
+ """Initialize the climate."""
+ super().__init__(device)
+ self._device = atw_device
+ self._zone = atw_zone
+
+ @property
+ def unique_id(self) -> Optional[str]:
+ """Return a unique ID."""
+ return f"{self.api.device.serial}-{self._zone.zone_index}"
+
+ @property
+ def name(self) -> str:
+ """Return the display name of this entity."""
+ return f"{self._name} {self._zone.name}"
+
+ @property
+ def device_state_attributes(self) -> Dict[str, Any]:
+ """Return the optional state attributes with device specific additions."""
+ data = {
+ ATTR_STATUS: ATW_ZONE_HVAC_MODE_LOOKUP.get(
+ self._zone.status, self._zone.status
+ )
+ }
+ return data
+
+ @property
+ def temperature_unit(self) -> str:
+ """Return the unit of measurement used by the platform."""
+ return TEMP_UNIT_LOOKUP.get(self._device.temp_unit, TEMP_CELSIUS)
+
+ @property
+ def hvac_mode(self) -> str:
+ """Return hvac operation ie. heat, cool mode."""
+ mode = self._zone.operation_mode
+ if not self._device.power or mode is None:
+ return HVAC_MODE_OFF
+ return ATW_ZONE_HVAC_MODE_LOOKUP.get(mode, HVAC_MODE_OFF)
+
+ async def async_set_hvac_mode(self, hvac_mode: str) -> None:
+ """Set new target hvac mode."""
+ if hvac_mode == HVAC_MODE_OFF:
+ await self._device.set({"power": False})
+ return
+
+ operation_mode = ATW_ZONE_HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode)
+ if operation_mode is None:
+ raise ValueError(f"Invalid hvac_mode [{hvac_mode}]")
+
+ if self._zone.zone_index == 1:
+ props = {PROPERTY_ZONE_1_OPERATION_MODE: operation_mode}
+ else:
+ props = {PROPERTY_ZONE_2_OPERATION_MODE: operation_mode}
+ if self.hvac_mode == HVAC_MODE_OFF:
+ props["power"] = True
+ await self._device.set(props)
+
+ @property
+ def hvac_modes(self) -> List[str]:
+ """Return the list of available hvac operation modes."""
+ return [self.hvac_mode]
+
+ @property
+ def current_temperature(self) -> Optional[float]:
+ """Return the current temperature."""
+ return self._zone.room_temperature
+
+ @property
+ def target_temperature(self) -> Optional[float]:
+ """Return the temperature we try to reach."""
+ return self._zone.target_temperature
+
+ async def async_set_temperature(self, **kwargs) -> None:
+ """Set new target temperature."""
+ await self._zone.set_target_temperature(
+ kwargs.get("temperature", self.target_temperature)
+ )
+
+ @property
+ def supported_features(self) -> int:
+ """Return the list of supported features."""
+ return SUPPORT_TARGET_TEMPERATURE
+
+ @property
+ def min_temp(self) -> float:
+ """Return the minimum temperature.
+
+ MELCloud API does not expose radiator zone temperature limits.
+ """
+ return convert_temperature(10, TEMP_CELSIUS, self.temperature_unit)
+
+ @property
+ def max_temp(self) -> float:
+ """Return the maximum temperature.
+
+ MELCloud API does not expose radiator zone temperature limits.
+ """
+ return convert_temperature(30, TEMP_CELSIUS, self.temperature_unit)
diff --git a/homeassistant/components/melcloud/const.py b/homeassistant/components/melcloud/const.py
index e262be2c3fb..c6ce4391294 100644
--- a/homeassistant/components/melcloud/const.py
+++ b/homeassistant/components/melcloud/const.py
@@ -1,26 +1,11 @@
"""Constants for the MELCloud Climate integration."""
-import pymelcloud.ata_device as ata_device
from pymelcloud.const import UNIT_TEMP_CELSIUS, UNIT_TEMP_FAHRENHEIT
-from homeassistant.components.climate.const import (
- HVAC_MODE_COOL,
- HVAC_MODE_DRY,
- HVAC_MODE_FAN_ONLY,
- HVAC_MODE_HEAT,
- HVAC_MODE_HEAT_COOL,
-)
from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
DOMAIN = "melcloud"
-HVAC_MODE_LOOKUP = {
- ata_device.OPERATION_MODE_HEAT: HVAC_MODE_HEAT,
- ata_device.OPERATION_MODE_DRY: HVAC_MODE_DRY,
- ata_device.OPERATION_MODE_COOL: HVAC_MODE_COOL,
- ata_device.OPERATION_MODE_FAN_ONLY: HVAC_MODE_FAN_ONLY,
- ata_device.OPERATION_MODE_HEAT_COOL: HVAC_MODE_HEAT_COOL,
-}
-HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in HVAC_MODE_LOOKUP.items()}
+ATTR_STATUS = "status"
TEMP_UNIT_LOOKUP = {
UNIT_TEMP_CELSIUS: TEMP_CELSIUS,
diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json
index 55edcdd0d9f..61fc9e1b730 100644
--- a/homeassistant/components/melcloud/manifest.json
+++ b/homeassistant/components/melcloud/manifest.json
@@ -3,7 +3,7 @@
"name": "MELCloud",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/melcloud",
- "requirements": ["pymelcloud==2.1.0"],
+ "requirements": ["pymelcloud==2.4.0"],
"dependencies": [],
"codeowners": ["@vilppuvuorinen"]
}
diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py
index 8f55906443e..31bfd005ac1 100644
--- a/homeassistant/components/melcloud/sensor.py
+++ b/homeassistant/components/melcloud/sensor.py
@@ -1,7 +1,8 @@
"""Support for MelCloud device sensors."""
import logging
-from pymelcloud import DEVICE_TYPE_ATA
+from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW
+from pymelcloud.atw_device import Zone
from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS
from homeassistant.helpers.entity import Entity
@@ -16,7 +17,7 @@ ATTR_DEVICE_CLASS = "device_class"
ATTR_VALUE_FN = "value_fn"
ATTR_ENABLED_FN = "enabled"
-SENSORS = {
+ATA_SENSORS = {
"room_temperature": {
ATTR_MEASUREMENT_NAME: "Room Temperature",
ATTR_ICON: "mdi:thermometer",
@@ -34,6 +35,34 @@ SENSORS = {
ATTR_ENABLED_FN: lambda x: x.device.has_energy_consumed_meter,
},
}
+ATW_SENSORS = {
+ "outside_temperature": {
+ ATTR_MEASUREMENT_NAME: "Outside Temperature",
+ ATTR_ICON: "mdi:thermometer",
+ ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS),
+ ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
+ ATTR_VALUE_FN: lambda x: x.device.outside_temperature,
+ ATTR_ENABLED_FN: lambda x: True,
+ },
+ "tank_temperature": {
+ ATTR_MEASUREMENT_NAME: "Tank Temperature",
+ ATTR_ICON: "mdi:thermometer",
+ ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS),
+ ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
+ ATTR_VALUE_FN: lambda x: x.device.tank_temperature,
+ ATTR_ENABLED_FN: lambda x: True,
+ },
+}
+ATW_ZONE_SENSORS = {
+ "room_temperature": {
+ ATTR_MEASUREMENT_NAME: "Room Temperature",
+ ATTR_ICON: "mdi:thermometer",
+ ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS),
+ ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
+ ATTR_VALUE_FN: lambda zone: zone.room_temperature,
+ ATTR_ENABLED_FN: lambda x: True,
+ }
+}
_LOGGER = logging.getLogger(__name__)
@@ -43,22 +72,35 @@ async def async_setup_entry(hass, entry, async_add_entities):
mel_devices = hass.data[DOMAIN].get(entry.entry_id)
async_add_entities(
[
- MelCloudSensor(mel_device, measurement, definition)
- for measurement, definition in SENSORS.items()
+ MelDeviceSensor(mel_device, measurement, definition)
+ for measurement, definition in ATA_SENSORS.items()
for mel_device in mel_devices[DEVICE_TYPE_ATA]
if definition[ATTR_ENABLED_FN](mel_device)
+ ]
+ + [
+ MelDeviceSensor(mel_device, measurement, definition)
+ for measurement, definition in ATW_SENSORS.items()
+ for mel_device in mel_devices[DEVICE_TYPE_ATW]
+ if definition[ATTR_ENABLED_FN](mel_device)
+ ]
+ + [
+ AtwZoneSensor(mel_device, zone, measurement, definition)
+ for mel_device in mel_devices[DEVICE_TYPE_ATW]
+ for zone in mel_device.device.zones
+ for measurement, definition, in ATW_ZONE_SENSORS.items()
+ if definition[ATTR_ENABLED_FN](zone)
],
True,
)
-class MelCloudSensor(Entity):
+class MelDeviceSensor(Entity):
"""Representation of a Sensor."""
- def __init__(self, device: MelCloudDevice, measurement, definition):
+ def __init__(self, api: MelCloudDevice, measurement, definition):
"""Initialize the sensor."""
- self._api = device
- self._name_slug = device.name
+ self._api = api
+ self._name_slug = api.name
self._measurement = measurement
self._def = definition
@@ -100,3 +142,20 @@ class MelCloudSensor(Entity):
def device_info(self):
"""Return a device description for device registry."""
return self._api.device_info
+
+
+class AtwZoneSensor(MelDeviceSensor):
+ """Air-to-Air device sensor."""
+
+ def __init__(
+ self, api: MelCloudDevice, zone: Zone, measurement, definition,
+ ):
+ """Initialize the sensor."""
+ super().__init__(api, measurement, definition)
+ self._zone = zone
+ self._name_slug = f"{api.name} {zone.name}"
+
+ @property
+ def state(self):
+ """Return zone based state."""
+ return self._def[ATTR_VALUE_FN](self._zone)
diff --git a/homeassistant/components/melcloud/water_heater.py b/homeassistant/components/melcloud/water_heater.py
new file mode 100644
index 00000000000..fa7aff2b640
--- /dev/null
+++ b/homeassistant/components/melcloud/water_heater.py
@@ -0,0 +1,132 @@
+"""Platform for water_heater integration."""
+from typing import List, Optional
+
+from pymelcloud import DEVICE_TYPE_ATW, AtwDevice
+from pymelcloud.atw_device import (
+ PROPERTY_OPERATION_MODE,
+ PROPERTY_TARGET_TANK_TEMPERATURE,
+)
+from pymelcloud.device import PROPERTY_POWER
+
+from homeassistant.components.water_heater import (
+ SUPPORT_OPERATION_MODE,
+ SUPPORT_TARGET_TEMPERATURE,
+ WaterHeaterDevice,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import TEMP_CELSIUS
+from homeassistant.helpers.typing import HomeAssistantType
+
+from . import DOMAIN, MelCloudDevice
+from .const import ATTR_STATUS, TEMP_UNIT_LOOKUP
+
+
+async def async_setup_entry(
+ hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
+):
+ """Set up MelCloud device climate based on config_entry."""
+ mel_devices = hass.data[DOMAIN][entry.entry_id]
+ async_add_entities(
+ [
+ AtwWaterHeater(mel_device, mel_device.device)
+ for mel_device in mel_devices[DEVICE_TYPE_ATW]
+ ],
+ True,
+ )
+
+
+class AtwWaterHeater(WaterHeaterDevice):
+ """Air-to-Water water heater."""
+
+ def __init__(self, api: MelCloudDevice, device: AtwDevice) -> None:
+ """Initialize water heater device."""
+ self._api = api
+ self._device = device
+ self._name = device.name
+
+ async def async_update(self):
+ """Update state from MELCloud."""
+ await self._api.async_update()
+
+ @property
+ def unique_id(self) -> Optional[str]:
+ """Return a unique ID."""
+ return f"{self._api.device.serial}"
+
+ @property
+ def name(self):
+ """Return the display name of this entity."""
+ return self._name
+
+ @property
+ def device_info(self):
+ """Return a device description for device registry."""
+ return self._api.device_info
+
+ async def async_turn_on(self) -> None:
+ """Turn the entity on."""
+ await self._device.set({PROPERTY_POWER: True})
+
+ async def async_turn_off(self) -> None:
+ """Turn the entity off."""
+ await self._device.set({PROPERTY_POWER: False})
+
+ @property
+ def device_state_attributes(self):
+ """Return the optional state attributes with device specific additions."""
+ data = {ATTR_STATUS: self._device.status}
+ return data
+
+ @property
+ def temperature_unit(self) -> str:
+ """Return the unit of measurement used by the platform."""
+ return TEMP_UNIT_LOOKUP.get(self._device.temp_unit, TEMP_CELSIUS)
+
+ @property
+ def current_operation(self) -> Optional[str]:
+ """Return current operation as reported by pymelcloud."""
+ return self._device.operation_mode
+
+ @property
+ def operation_list(self) -> List[str]:
+ """Return the list of available operation modes as reported by pymelcloud."""
+ return self._device.operation_modes
+
+ @property
+ def current_temperature(self) -> Optional[float]:
+ """Return the current temperature."""
+ return self._device.tank_temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._device.target_tank_temperature
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ await self._device.set(
+ {
+ PROPERTY_TARGET_TANK_TEMPERATURE: kwargs.get(
+ "temperature", self.target_temperature
+ )
+ }
+ )
+
+ async def async_set_operation_mode(self, operation_mode):
+ """Set new target operation mode."""
+ await self._device.set({PROPERTY_OPERATION_MODE: operation_mode})
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE
+
+ @property
+ def min_temp(self) -> Optional[float]:
+ """Return the minimum temperature."""
+ return self._device.target_tank_temperature_min
+
+ @property
+ def max_temp(self) -> Optional[float]:
+ """Return the maximum temperature."""
+ return self._device.target_tank_temperature_max
diff --git a/homeassistant/components/meraki/device_tracker.py b/homeassistant/components/meraki/device_tracker.py
index 1aa1485922e..614c2943530 100644
--- a/homeassistant/components/meraki/device_tracker.py
+++ b/homeassistant/components/meraki/device_tracker.py
@@ -1,10 +1,4 @@
-"""
-Support for the Meraki CMX location service.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.meraki/
-
-"""
+"""Support for the Meraki CMX location service."""
import json
import logging
diff --git a/homeassistant/components/met/const.py b/homeassistant/components/met/const.py
index e60dab5072f..f29f034df68 100644
--- a/homeassistant/components/met/const.py
+++ b/homeassistant/components/met/const.py
@@ -9,7 +9,6 @@ HOME_LOCATION_NAME = "Home"
CONF_TRACK_HOME = "track_home"
-ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".met_{}"
-ENTITY_ID_SENSOR_FORMAT_HOME = ENTITY_ID_SENSOR_FORMAT.format(HOME_LOCATION_NAME)
+ENTITY_ID_SENSOR_FORMAT_HOME = f"{WEATHER_DOMAIN}.met_{HOME_LOCATION_NAME}"
_LOGGER = logging.getLogger(".")
diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py
index d99573a985e..13150098452 100644
--- a/homeassistant/components/met/weather.py
+++ b/homeassistant/components/met/weather.py
@@ -168,7 +168,7 @@ class MetWeather(WeatherEntity):
if self.track_home:
return "home"
- return "{}-{}".format(self._config[CONF_LATITUDE], self._config[CONF_LONGITUDE])
+ return f"{self._config[CONF_LATITUDE]}-{self._config[CONF_LONGITUDE]}"
@property
def name(self):
diff --git a/homeassistant/components/meteo_france/.translations/lv.json b/homeassistant/components/meteo_france/.translations/lv.json
new file mode 100644
index 00000000000..478931242df
--- /dev/null
+++ b/homeassistant/components/meteo_france/.translations/lv.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Pils\u0113ta jau ir konfigur\u0113ta"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "city": "Pils\u0113ta"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py
index fae2000b19a..b7647f5d97b 100644
--- a/homeassistant/components/meteo_france/const.py
+++ b/homeassistant/components/meteo_france/const.py
@@ -1,6 +1,11 @@
"""Meteo-France component constants."""
-from homeassistant.const import TEMP_CELSIUS
+from homeassistant.const import (
+ SPEED_KILOMETERS_PER_HOUR,
+ TEMP_CELSIUS,
+ TIME_MINUTES,
+ UNIT_PERCENTAGE,
+)
DOMAIN = "meteo_france"
PLATFORMS = ["sensor", "weather"]
@@ -17,25 +22,25 @@ SENSOR_TYPE_CLASS = "device_class"
SENSOR_TYPES = {
"rain_chance": {
SENSOR_TYPE_NAME: "Rain chance",
- SENSOR_TYPE_UNIT: "%",
+ SENSOR_TYPE_UNIT: UNIT_PERCENTAGE,
SENSOR_TYPE_ICON: "mdi:weather-rainy",
SENSOR_TYPE_CLASS: None,
},
"freeze_chance": {
SENSOR_TYPE_NAME: "Freeze chance",
- SENSOR_TYPE_UNIT: "%",
+ SENSOR_TYPE_UNIT: UNIT_PERCENTAGE,
SENSOR_TYPE_ICON: "mdi:snowflake",
SENSOR_TYPE_CLASS: None,
},
"thunder_chance": {
SENSOR_TYPE_NAME: "Thunder chance",
- SENSOR_TYPE_UNIT: "%",
+ SENSOR_TYPE_UNIT: UNIT_PERCENTAGE,
SENSOR_TYPE_ICON: "mdi:weather-lightning",
SENSOR_TYPE_CLASS: None,
},
"snow_chance": {
SENSOR_TYPE_NAME: "Snow chance",
- SENSOR_TYPE_UNIT: "%",
+ SENSOR_TYPE_UNIT: UNIT_PERCENTAGE,
SENSOR_TYPE_ICON: "mdi:weather-snowy",
SENSOR_TYPE_CLASS: None,
},
@@ -47,13 +52,13 @@ SENSOR_TYPES = {
},
"wind_speed": {
SENSOR_TYPE_NAME: "Wind Speed",
- SENSOR_TYPE_UNIT: "km/h",
+ SENSOR_TYPE_UNIT: SPEED_KILOMETERS_PER_HOUR,
SENSOR_TYPE_ICON: "mdi:weather-windy",
SENSOR_TYPE_CLASS: None,
},
"next_rain": {
SENSOR_TYPE_NAME: "Next rain",
- SENSOR_TYPE_UNIT: "min",
+ SENSOR_TYPE_UNIT: TIME_MINUTES,
SENSOR_TYPE_ICON: "mdi:weather-rainy",
SENSOR_TYPE_CLASS: None,
},
diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py
index 98d94ebe6ca..d04f7c5f582 100644
--- a/homeassistant/components/metoffice/sensor.py
+++ b/homeassistant/components/metoffice/sensor.py
@@ -13,7 +13,9 @@ from homeassistant.const import (
CONF_LONGITUDE,
CONF_MONITORED_CONDITIONS,
CONF_NAME,
+ SPEED_MILES_PER_HOUR,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -64,14 +66,14 @@ SENSOR_TYPES = {
"weather": ["Weather", None],
"temperature": ["Temperature", TEMP_CELSIUS],
"feels_like_temperature": ["Feels Like Temperature", TEMP_CELSIUS],
- "wind_speed": ["Wind Speed", "mph"],
+ "wind_speed": ["Wind Speed", SPEED_MILES_PER_HOUR],
"wind_direction": ["Wind Direction", None],
- "wind_gust": ["Wind Gust", "mph"],
+ "wind_gust": ["Wind Gust", SPEED_MILES_PER_HOUR],
"visibility": ["Visibility", None],
"visibility_distance": ["Visibility Distance", "km"],
"uv": ["UV", None],
- "precipitation": ["Probability of Precipitation", "%"],
- "humidity": ["Humidity", "%"],
+ "precipitation": ["Probability of Precipitation", UNIT_PERCENTAGE],
+ "humidity": ["Humidity", UNIT_PERCENTAGE],
}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@@ -139,7 +141,7 @@ class MetOfficeCurrentSensor(Entity):
@property
def name(self):
"""Return the name of the sensor."""
- return "{} {}".format(self._name, SENSOR_TYPES[self._condition][0])
+ return f"{self._name} {SENSOR_TYPES[self._condition][0]}"
@property
def state(self):
diff --git a/homeassistant/components/mhz19/sensor.py b/homeassistant/components/mhz19/sensor.py
index aedd5ea9b09..892895b9e02 100644
--- a/homeassistant/components/mhz19/sensor.py
+++ b/homeassistant/components/mhz19/sensor.py
@@ -8,6 +8,7 @@ import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import (
ATTR_TEMPERATURE,
+ CONCENTRATION_PARTS_PER_MILLION,
CONF_MONITORED_CONDITIONS,
CONF_NAME,
TEMP_FAHRENHEIT,
@@ -28,7 +29,10 @@ ATTR_CO2_CONCENTRATION = "co2_concentration"
SENSOR_TEMPERATURE = "temperature"
SENSOR_CO2 = "co2"
-SENSOR_TYPES = {SENSOR_TEMPERATURE: ["Temperature", None], SENSOR_CO2: ["CO2", "ppm"]}
+SENSOR_TYPES = {
+ SENSOR_TEMPERATURE: ["Temperature", None],
+ SENSOR_CO2: ["CO2", CONCENTRATION_PARTS_PER_MILLION],
+}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
@@ -81,7 +85,7 @@ class MHZ19Sensor(Entity):
@property
def name(self):
"""Return the name of the sensor."""
- return "{}: {}".format(self._name, SENSOR_TYPES[self._sensor_type][0])
+ return f"{self._name}: {SENSOR_TYPES[self._sensor_type][0]}"
@property
def state(self):
diff --git a/homeassistant/components/microsoft/tts.py b/homeassistant/components/microsoft/tts.py
index 074605e07fe..c857cc731f4 100644
--- a/homeassistant/components/microsoft/tts.py
+++ b/homeassistant/components/microsoft/tts.py
@@ -6,7 +6,7 @@ from pycsspeechtts import pycsspeechtts
import voluptuous as vol
from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider
-from homeassistant.const import CONF_API_KEY, CONF_TYPE
+from homeassistant.const import CONF_API_KEY, CONF_TYPE, UNIT_PERCENTAGE
import homeassistant.helpers.config_validation as cv
CONF_GENDER = "gender"
@@ -122,8 +122,8 @@ class MicrosoftProvider(Provider):
self._gender = gender
self._type = ttype
self._output = DEFAULT_OUTPUT
- self._rate = f"{rate}%"
- self._volume = f"{volume}%"
+ self._rate = f"{rate}{UNIT_PERCENTAGE}"
+ self._volume = f"{volume}{UNIT_PERCENTAGE}"
self._pitch = pitch
self._contour = contour
self._region = region
diff --git a/homeassistant/components/microsoft_face_detect/image_processing.py b/homeassistant/components/microsoft_face_detect/image_processing.py
index c10f7edf9db..3b0580a3eeb 100644
--- a/homeassistant/components/microsoft_face_detect/image_processing.py
+++ b/homeassistant/components/microsoft_face_detect/image_processing.py
@@ -73,7 +73,7 @@ class MicrosoftFaceDetectEntity(ImageProcessingFaceEntity):
if name:
self._name = name
else:
- self._name = "MicrosoftFace {0}".format(split_entity_id(camera_entity)[1])
+ self._name = f"MicrosoftFace {split_entity_id(camera_entity)[1]}"
@property
def camera_entity(self):
diff --git a/homeassistant/components/microsoft_face_identify/image_processing.py b/homeassistant/components/microsoft_face_identify/image_processing.py
index 820292eb365..1065e64a110 100644
--- a/homeassistant/components/microsoft_face_identify/image_processing.py
+++ b/homeassistant/components/microsoft_face_identify/image_processing.py
@@ -61,7 +61,7 @@ class MicrosoftFaceIdentifyEntity(ImageProcessingFaceEntity):
if name:
self._name = name
else:
- self._name = "MicrosoftFace {0}".format(split_entity_id(camera_entity)[1])
+ self._name = f"MicrosoftFace {split_entity_id(camera_entity)[1]}"
@property
def confidence(self):
diff --git a/homeassistant/components/miflora/sensor.py b/homeassistant/components/miflora/sensor.py
index 815a6e97bb8..bd551517562 100644
--- a/homeassistant/components/miflora/sensor.py
+++ b/homeassistant/components/miflora/sensor.py
@@ -15,6 +15,7 @@ from homeassistant.const import (
CONF_NAME,
CONF_SCAN_INTERVAL,
EVENT_HOMEASSISTANT_START,
+ UNIT_PERCENTAGE,
)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
@@ -43,9 +44,9 @@ SCAN_INTERVAL = timedelta(seconds=1200)
SENSOR_TYPES = {
"temperature": ["Temperature", "°C", "mdi:thermometer"],
"light": ["Light intensity", "lx", "mdi:white-balance-sunny"],
- "moisture": ["Moisture", "%", "mdi:water-percent"],
+ "moisture": ["Moisture", UNIT_PERCENTAGE, "mdi:water-percent"],
"conductivity": ["Conductivity", "µS/cm", "mdi:flash-circle"],
- "battery": ["Battery", "%", "mdi:battery-charging"],
+ "battery": ["Battery", UNIT_PERCENTAGE, "mdi:battery-charging"],
}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
diff --git a/homeassistant/components/mikrotik/.translations/lv.json b/homeassistant/components/mikrotik/.translations/lv.json
new file mode 100644
index 00000000000..232c7d16173
--- /dev/null
+++ b/homeassistant/components/mikrotik/.translations/lv.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "Parole",
+ "port": "Ports",
+ "username": "Lietot\u0101jv\u0101rds",
+ "verify_ssl": "Izmantot SSL"
+ },
+ "title": "Iestat\u012bt Mikrotik mar\u0161rut\u0113t\u0101ju"
+ }
+ },
+ "title": "Mikrotik"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py
index 300d73b6b11..023bdc74a7e 100644
--- a/homeassistant/components/mikrotik/hub.py
+++ b/homeassistant/components/mikrotik/hub.py
@@ -332,16 +332,17 @@ class MikrotikHub:
async def async_add_options(self):
"""Populate default options for Mikrotik."""
if not self.config_entry.options:
+ data = dict(self.config_entry.data)
options = {
- CONF_ARP_PING: self.config_entry.data.pop(CONF_ARP_PING, False),
- CONF_FORCE_DHCP: self.config_entry.data.pop(CONF_FORCE_DHCP, False),
- CONF_DETECTION_TIME: self.config_entry.data.pop(
+ CONF_ARP_PING: data.pop(CONF_ARP_PING, False),
+ CONF_FORCE_DHCP: data.pop(CONF_FORCE_DHCP, False),
+ CONF_DETECTION_TIME: data.pop(
CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME
),
}
self.hass.config_entries.async_update_entry(
- self.config_entry, options=options
+ self.config_entry, data=data, options=options
)
async def request_update(self):
diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py
index 80beaf1f798..aa58cc0be21 100644
--- a/homeassistant/components/min_max/sensor.py
+++ b/homeassistant/components/min_max/sensor.py
@@ -115,9 +115,7 @@ class MinMaxSensor(Entity):
if name:
self._name = name
else:
- self._name = "{} sensor".format(
- next(v for k, v in SENSOR_TYPES.items() if self._sensor_type == v)
- ).capitalize()
+ self._name = f"{next(v for k, v in SENSOR_TYPES.items() if self._sensor_type == v)} sensor".capitalize()
self._unit_of_measurement = None
self._unit_of_measurement_mismatch = False
self.min_value = self.max_value = self.mean = self.last = None
diff --git a/homeassistant/components/minecraft_server/.translations/lv.json b/homeassistant/components/minecraft_server/.translations/lv.json
new file mode 100644
index 00000000000..7de2aaadfc8
--- /dev/null
+++ b/homeassistant/components/minecraft_server/.translations/lv.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "name": "Nosaukums",
+ "port": "Ports"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py
index c3ab6615481..d86faf23a81 100644
--- a/homeassistant/components/minecraft_server/const.py
+++ b/homeassistant/components/minecraft_server/const.py
@@ -30,7 +30,6 @@ SCAN_INTERVAL = 60
SIGNAL_NAME_PREFIX = f"signal_{DOMAIN}"
-UNIT_LATENCY_TIME = "ms"
UNIT_PLAYERS_MAX = "players"
UNIT_PLAYERS_ONLINE = "players"
UNIT_PROTOCOL_VERSION = None
diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py
index 0b37a7d979b..20f9e98e530 100644
--- a/homeassistant/components/minecraft_server/sensor.py
+++ b/homeassistant/components/minecraft_server/sensor.py
@@ -4,6 +4,7 @@ import logging
from typing import Any, Dict
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import TIME_MILLISECONDS
from homeassistant.helpers.typing import HomeAssistantType
from . import MinecraftServer, MinecraftServerEntity
@@ -20,7 +21,6 @@ from .const import (
NAME_PLAYERS_ONLINE,
NAME_PROTOCOL_VERSION,
NAME_VERSION,
- UNIT_LATENCY_TIME,
UNIT_PLAYERS_MAX,
UNIT_PLAYERS_ONLINE,
UNIT_PROTOCOL_VERSION,
@@ -121,7 +121,7 @@ class MinecraftServerLatencyTimeSensor(MinecraftServerSensorEntity):
server=server,
type_name=NAME_LATENCY_TIME,
icon=ICON_LATENCY_TIME,
- unit=UNIT_LATENCY_TIME,
+ unit=TIME_MILLISECONDS,
)
async def async_update(self) -> None:
diff --git a/homeassistant/components/mitemp_bt/sensor.py b/homeassistant/components/mitemp_bt/sensor.py
index b536149680d..febfb93cf1d 100644
--- a/homeassistant/components/mitemp_bt/sensor.py
+++ b/homeassistant/components/mitemp_bt/sensor.py
@@ -15,6 +15,7 @@ from homeassistant.const import (
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -46,8 +47,8 @@ DEFAULT_TIMEOUT = 10
# Sensor types are defined like: Name, units
SENSOR_TYPES = {
"temperature": [DEVICE_CLASS_TEMPERATURE, "Temperature", "°C"],
- "humidity": [DEVICE_CLASS_HUMIDITY, "Humidity", "%"],
- "battery": [DEVICE_CLASS_BATTERY, "Battery", "%"],
+ "humidity": [DEVICE_CLASS_HUMIDITY, "Humidity", UNIT_PERCENTAGE],
+ "battery": [DEVICE_CLASS_BATTERY, "Battery", UNIT_PERCENTAGE],
}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py
index fcf95da586e..4396f8c8e0c 100644
--- a/homeassistant/components/mobile_app/__init__.py
+++ b/homeassistant/components/mobile_app/__init__.py
@@ -93,7 +93,7 @@ async def async_setup_entry(hass, entry):
hass.data[DOMAIN][DATA_DEVICES][webhook_id] = device
- registration_name = "Mobile App: {}".format(registration[ATTR_DEVICE_NAME])
+ registration_name = f"Mobile App: {registration[ATTR_DEVICE_NAME]}"
webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook)
for domain in PLATFORMS:
diff --git a/homeassistant/components/mobile_app/config_flow.py b/homeassistant/components/mobile_app/config_flow.py
index 6fc4b342298..08fdecf364d 100644
--- a/homeassistant/components/mobile_app/config_flow.py
+++ b/homeassistant/components/mobile_app/config_flow.py
@@ -18,7 +18,7 @@ class MobileAppFlowHandler(config_entries.ConfigFlow):
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
placeholders = {
- "apps_url": "https://www.home-assistant.io/components/mobile_app/#apps"
+ "apps_url": "https://www.home-assistant.io/integrations/mobile_app/#apps"
}
return self.async_abort(
diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py
index 480bfee512f..850d17212fd 100644
--- a/homeassistant/components/mobile_app/device_tracker.py
+++ b/homeassistant/components/mobile_app/device_tracker.py
@@ -100,11 +100,6 @@ class MobileAppEntity(TrackerEntity, RestoreEntity):
"""Return the name of the device."""
return self._entry.data[ATTR_DEVICE_NAME]
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
@property
def source_type(self):
"""Return the source type, eg gps or router, of the device."""
diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py
index b16c47e29c0..00f4577ad9e 100644
--- a/homeassistant/components/mobile_app/notify.py
+++ b/homeassistant/components/mobile_app/notify.py
@@ -139,8 +139,8 @@ class MobileAppNotificationService(BaseNotificationService):
continue
fallback_error = result.get("errorMessage", "Unknown error")
- fallback_message = "Internal server error, please try again later: {}".format(
- fallback_error
+ fallback_message = (
+ f"Internal server error, please try again later: {fallback_error}"
)
message = result.get("message", fallback_message)
if response.status == 429:
diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py
index c47f38986a1..adc90c15e98 100644
--- a/homeassistant/components/mobile_app/webhook.py
+++ b/homeassistant/components/mobile_app/webhook.py
@@ -381,7 +381,7 @@ async def webhook_register_sensor(hass, config_entry, data):
_LOGGER.error("Error registering sensor: %s", ex)
return empty_okay_response()
- register_signal = "{}_{}_register".format(DOMAIN, data[ATTR_SENSOR_TYPE])
+ register_signal = f"{DOMAIN}_{data[ATTR_SENSOR_TYPE]}_register"
async_dispatcher_send(hass, register_signal, data)
return webhook_response(
diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py
index c0423849418..f83b7d7b901 100644
--- a/homeassistant/components/modbus/climate.py
+++ b/homeassistant/components/modbus/climate.py
@@ -162,7 +162,7 @@ class ModbusThermostat(ClimateDevice):
DATA_TYPE_FLOAT: {1: "e", 2: "f", 4: "d"},
}
- self._structure = ">{}".format(data_types[self._data_type][self._count])
+ self._structure = f">{data_types[self._data_type][self._count]}"
@property
def supported_features(self):
diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py
index 716cb5299b7..b586ad852df 100644
--- a/homeassistant/components/modbus/sensor.py
+++ b/homeassistant/components/modbus/sensor.py
@@ -99,8 +99,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
structure = ">i"
if register[CONF_DATA_TYPE] != DATA_TYPE_CUSTOM:
try:
- structure = ">{}".format(
- data_types[register[CONF_DATA_TYPE]][register[CONF_COUNT]]
+ structure = (
+ f">{data_types[register[CONF_DATA_TYPE]][register[CONF_COUNT]]}"
)
except KeyError:
_LOGGER.error(
diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py
index 0d6c6f55284..374866a6859 100644
--- a/homeassistant/components/mold_indicator/sensor.py
+++ b/homeassistant/components/mold_indicator/sensor.py
@@ -13,6 +13,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
@@ -245,7 +246,7 @@ class MoldIndicator(Entity):
)
return None
- if unit != "%":
+ if unit != UNIT_PERCENTAGE:
_LOGGER.error(
"Humidity sensor %s has unsupported unit: %s %s",
state.entity_id,
@@ -343,7 +344,7 @@ class MoldIndicator(Entity):
elif crit_humidity < 0:
self._state = "0"
else:
- self._state = "{0:d}".format(int(crit_humidity))
+ self._state = f"{int(crit_humidity):d}"
_LOGGER.debug("Mold indicator humidity: %s", self._state)
@@ -360,7 +361,7 @@ class MoldIndicator(Entity):
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
- return "%"
+ return UNIT_PERCENTAGE
@property
def state(self):
diff --git a/homeassistant/components/mopar/__init__.py b/homeassistant/components/mopar/__init__.py
index 21a3c3d16ea..4801a7c43d6 100644
--- a/homeassistant/components/mopar/__init__.py
+++ b/homeassistant/components/mopar/__init__.py
@@ -127,7 +127,7 @@ class MoparData:
vehicle = self.vehicles[index]
if not vehicle:
return None
- return "{} {} {}".format(vehicle["year"], vehicle["make"], vehicle["model"])
+ return f"{vehicle['year']} {vehicle['make']} {vehicle['model']}"
def actuate(self, command, index):
"""Run a command on the specified Mopar vehicle."""
diff --git a/homeassistant/components/mopar/lock.py b/homeassistant/components/mopar/lock.py
index 49e25ad30c0..3933e567723 100644
--- a/homeassistant/components/mopar/lock.py
+++ b/homeassistant/components/mopar/lock.py
@@ -22,7 +22,7 @@ class MoparLock(LockDevice):
def __init__(self, data, index):
"""Initialize the Mopar lock."""
self._index = index
- self._name = "{} Lock".format(data.get_vehicle_name(self._index))
+ self._name = f"{data.get_vehicle_name(self._index)} Lock"
self._actuate = data.actuate
self._state = None
diff --git a/homeassistant/components/mopar/switch.py b/homeassistant/components/mopar/switch.py
index 2dad56637ce..c7a8c762fbc 100644
--- a/homeassistant/components/mopar/switch.py
+++ b/homeassistant/components/mopar/switch.py
@@ -22,7 +22,7 @@ class MoparSwitch(SwitchDevice):
def __init__(self, data, index):
"""Initialize the Switch."""
self._index = index
- self._name = "{} Switch".format(data.get_vehicle_name(self._index))
+ self._name = f"{data.get_vehicle_name(self._index)} Switch"
self._actuate = data.actuate
self._state = None
diff --git a/homeassistant/components/mqtt/.translations/lb.json b/homeassistant/components/mqtt/.translations/lb.json
index 9467ab8a9a7..25814e1359b 100644
--- a/homeassistant/components/mqtt/.translations/lb.json
+++ b/homeassistant/components/mqtt/.translations/lb.json
@@ -38,6 +38,16 @@
"button_6": "Sechste Kn\u00e4ppchen",
"turn_off": "Ausschalten",
"turn_on": "Uschalten"
+ },
+ "trigger_type": {
+ "button_double_press": "\"{subtype}\" zwee mol gedr\u00e9ckt",
+ "button_long_press": "\"{subtype}\" permanent gedr\u00e9ckt",
+ "button_long_release": "\"{subtype}\" no laangem unhalen lassgelooss",
+ "button_quadruple_press": "\"{subtype}\" v\u00e9ier mol gedr\u00e9ckt",
+ "button_quintuple_press": "\"{subtype}\" f\u00ebnnef mol gedr\u00e9ckt",
+ "button_short_press": "\"{subtype}\" gedr\u00e9ckt",
+ "button_short_release": "\"{subtype}\" lassgelooss",
+ "button_triple_press": "\"{subtype}\" dr\u00e4imol gedr\u00e9ckt"
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/lv.json b/homeassistant/components/mqtt/.translations/lv.json
new file mode 100644
index 00000000000..2ff60e6ad84
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/lv.json
@@ -0,0 +1,14 @@
+{
+ "device_automation": {
+ "trigger_subtype": {
+ "button_1": "Pirm\u0101 poga",
+ "button_2": "Otr\u0101 poga",
+ "button_3": "Tre\u0161\u0101 poga",
+ "button_4": "Ceturt\u0101 poga",
+ "button_5": "Piekt\u0101 poga",
+ "button_6": "Sest\u0101 poga",
+ "turn_off": "Iesl\u0113gt",
+ "turn_on": "Iesl\u0113gt"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py
index 540d09d7c9f..fbbf4f42d7a 100644
--- a/homeassistant/components/mqtt/__init__.py
+++ b/homeassistant/components/mqtt/__init__.py
@@ -37,6 +37,7 @@ 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
@@ -48,6 +49,7 @@ from homeassistant.util.logging import catch_log_exception
from . import config_flow, discovery, server # noqa: F401 pylint: disable=unused-import
from .const import (
ATTR_DISCOVERY_HASH,
+ ATTR_DISCOVERY_TOPIC,
CONF_BROKER,
CONF_DISCOVERY,
CONF_STATE_TOPIC,
@@ -435,8 +437,9 @@ async def async_subscribe(
topic,
catch_log_exception(
wrapped_msg_callback,
- lambda msg: "Exception in {} when handling msg on '{}': '{}'".format(
- msg_callback.__name__, msg.topic, msg.payload
+ lambda msg: (
+ f"Exception in {msg_callback.__name__} when handling msg on "
+ f"'{msg.topic}': '{msg.payload}'"
),
),
qos,
@@ -510,6 +513,7 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
hass.data[DATA_MQTT_HASS_CONFIG] = config
websocket_api.async_register_command(hass, websocket_subscribe)
+ websocket_api.async_register_command(hass, websocket_remove_device)
if conf is None:
# If we have a config entry, setup is done by that config entry.
@@ -565,7 +569,7 @@ async def async_setup_entry(hass, entry):
# If user didn't have configuration.yaml config, generate defaults
if conf is None:
- conf = CONFIG_SCHEMA({DOMAIN: entry.data})[DOMAIN]
+ conf = CONFIG_SCHEMA({DOMAIN: dict(entry.data)})[DOMAIN]
elif any(key in conf for key in entry.data):
_LOGGER.warning(
"Data in your configuration entry is going to override your "
@@ -1011,7 +1015,7 @@ def _raise_on_error(result_code: int) -> None:
if result_code != 0:
raise HomeAssistantError(
- "Error talking to MQTT: {}".format(mqtt.error_string(result_code))
+ f"Error talking to MQTT: {mqtt.error_string(result_code)}"
)
@@ -1156,43 +1160,55 @@ class MqttAvailability(Entity):
class MqttDiscoveryUpdate(Entity):
"""Mixin used to handle updated discovery message."""
- def __init__(self, discovery_hash, discovery_update=None) -> None:
+ def __init__(self, discovery_data, discovery_update=None) -> None:
"""Initialize the discovery update mixin."""
- self._discovery_hash = discovery_hash
+ self._discovery_data = discovery_data
self._discovery_update = discovery_update
self._remove_signal = None
async def async_added_to_hass(self) -> None:
"""Subscribe to discovery updates."""
await super().async_added_to_hass()
+ discovery_hash = (
+ self._discovery_data[ATTR_DISCOVERY_HASH] if self._discovery_data else None
+ )
@callback
def discovery_callback(payload):
"""Handle discovery update."""
_LOGGER.info(
- "Got update for entity with hash: %s '%s'",
- self._discovery_hash,
- payload,
+ "Got update for entity with hash: %s '%s'", discovery_hash, payload,
)
if not payload:
# Empty payload: Remove component
_LOGGER.info("Removing component: %s", self.entity_id)
self.hass.async_create_task(self.async_remove())
- clear_discovery_hash(self.hass, self._discovery_hash)
+ clear_discovery_hash(self.hass, discovery_hash)
self._remove_signal()
elif self._discovery_update:
# Non-empty payload: Notify component
_LOGGER.info("Updating component: %s", self.entity_id)
- payload.pop(ATTR_DISCOVERY_HASH)
self.hass.async_create_task(self._discovery_update(payload))
- if self._discovery_hash:
+ if discovery_hash:
self._remove_signal = async_dispatcher_connect(
self.hass,
- MQTT_DISCOVERY_UPDATED.format(self._discovery_hash),
+ MQTT_DISCOVERY_UPDATED.format(discovery_hash),
discovery_callback,
)
+ async def async_removed_from_registry(self) -> None:
+ """Clear retained discovery topic in broker."""
+ discovery_topic = self._discovery_data[ATTR_DISCOVERY_TOPIC]
+ publish(
+ self.hass, discovery_topic, "", retain=True,
+ )
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Stop listening to signal."""
+ if self._remove_signal:
+ self._remove_signal()
+
def device_info_from_config(config):
"""Return a device description for device registry."""
@@ -1247,6 +1263,35 @@ class MqttEntityDeviceInfo(Entity):
return device_info_from_config(self._device_config)
+@websocket_api.websocket_command(
+ {vol.Required("type"): "mqtt/device/remove", vol.Required("device_id"): str}
+)
+@websocket_api.async_response
+async def websocket_remove_device(hass, connection, msg):
+ """Delete device."""
+ device_id = msg["device_id"]
+ dev_registry = await get_dev_reg(hass)
+
+ device = dev_registry.async_get(device_id)
+ if not device:
+ connection.send_error(
+ msg["id"], websocket_api.const.ERR_NOT_FOUND, "Device not found"
+ )
+ return
+
+ for config_entry in device.config_entries:
+ config_entry = hass.config_entries.async_get_entry(config_entry)
+ # Only delete the device if it belongs to an MQTT device entry
+ if config_entry.domain == DOMAIN:
+ dev_registry.async_remove_device(device_id)
+ connection.send_message(websocket_api.result_message(msg["id"]))
+ return
+
+ connection.send_error(
+ msg["id"], websocket_api.const.ERR_NOT_FOUND, "Non MQTT device"
+ )
+
+
@websocket_api.async_response
@websocket_api.websocket_command(
{
diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py
index 43d0bb570a8..043fa62f6ef 100644
--- a/homeassistant/components/mqtt/alarm_control_panel.py
+++ b/homeassistant/components/mqtt/alarm_control_panel.py
@@ -98,15 +98,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def async_discover(discovery_payload):
"""Discover and add an MQTT alarm control panel."""
+ discovery_data = discovery_payload.discovery_data
try:
- discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
config = PLATFORM_SCHEMA(discovery_payload)
await _async_setup_entity(
- config, async_add_entities, config_entry, discovery_hash
+ config, async_add_entities, config_entry, discovery_data
)
except Exception:
- if discovery_hash:
- clear_discovery_hash(hass, discovery_hash)
+ clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH])
raise
async_dispatcher_connect(
@@ -115,10 +114,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def _async_setup_entity(
- config, async_add_entities, config_entry=None, discovery_hash=None
+ config, async_add_entities, config_entry=None, discovery_data=None
):
"""Set up the MQTT Alarm Control Panel platform."""
- async_add_entities([MqttAlarm(config, config_entry, discovery_hash)])
+ async_add_entities([MqttAlarm(config, config_entry, discovery_data)])
class MqttAlarm(
@@ -130,7 +129,7 @@ class MqttAlarm(
):
"""Representation of a MQTT alarm status."""
- def __init__(self, config, config_entry, discovery_hash):
+ def __init__(self, config, config_entry, discovery_data):
"""Init the MQTT Alarm Control Panel."""
self._state = None
self._config = config
@@ -141,7 +140,7 @@ class MqttAlarm(
MqttAttributes.__init__(self, config)
MqttAvailability.__init__(self, config)
- MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update)
+ MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update)
MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
async def async_added_to_hass(self):
@@ -207,6 +206,7 @@ class MqttAlarm(
)
await MqttAttributes.async_will_remove_from_hass(self)
await MqttAvailability.async_will_remove_from_hass(self)
+ await MqttDiscoveryUpdate.async_will_remove_from_hass(self)
@property
def should_poll(self):
diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py
index fe47729561d..d268c12aa87 100644
--- a/homeassistant/components/mqtt/binary_sensor.py
+++ b/homeassistant/components/mqtt/binary_sensor.py
@@ -79,15 +79,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def async_discover(discovery_payload):
"""Discover and add a MQTT binary sensor."""
+ discovery_data = discovery_payload.discovery_data
try:
- discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
config = PLATFORM_SCHEMA(discovery_payload)
await _async_setup_entity(
- config, async_add_entities, config_entry, discovery_hash
+ config, async_add_entities, config_entry, discovery_data
)
except Exception:
- if discovery_hash:
- clear_discovery_hash(hass, discovery_hash)
+ clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH])
raise
async_dispatcher_connect(
@@ -96,10 +95,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def _async_setup_entity(
- config, async_add_entities, config_entry=None, discovery_hash=None
+ config, async_add_entities, config_entry=None, discovery_data=None
):
"""Set up the MQTT binary sensor."""
- async_add_entities([MqttBinarySensor(config, config_entry, discovery_hash)])
+ async_add_entities([MqttBinarySensor(config, config_entry, discovery_data)])
class MqttBinarySensor(
@@ -111,7 +110,7 @@ class MqttBinarySensor(
):
"""Representation a binary sensor that is updated by MQTT."""
- def __init__(self, config, config_entry, discovery_hash):
+ def __init__(self, config, config_entry, discovery_data):
"""Initialize the MQTT binary sensor."""
self._config = config
self._unique_id = config.get(CONF_UNIQUE_ID)
@@ -124,7 +123,7 @@ class MqttBinarySensor(
MqttAttributes.__init__(self, config)
MqttAvailability.__init__(self, config)
- MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update)
+ MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update)
MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
async def async_added_to_hass(self):
@@ -229,6 +228,7 @@ class MqttBinarySensor(
)
await MqttAttributes.async_will_remove_from_hass(self)
await MqttAvailability.async_will_remove_from_hass(self)
+ await MqttDiscoveryUpdate.async_will_remove_from_hass(self)
@callback
def value_is_expired(self, *_):
diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py
index 6cf0865ff6a..9bbb1503196 100644
--- a/homeassistant/components/mqtt/camera.py
+++ b/homeassistant/components/mqtt/camera.py
@@ -47,15 +47,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def async_discover(discovery_payload):
"""Discover and add a MQTT camera."""
+ discovery_data = discovery_payload.discovery_data
try:
- discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
config = PLATFORM_SCHEMA(discovery_payload)
await _async_setup_entity(
- config, async_add_entities, config_entry, discovery_hash
+ config, async_add_entities, config_entry, discovery_data
)
except Exception:
- if discovery_hash:
- clear_discovery_hash(hass, discovery_hash)
+ clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH])
raise
async_dispatcher_connect(
@@ -64,16 +63,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def _async_setup_entity(
- config, async_add_entities, config_entry=None, discovery_hash=None
+ config, async_add_entities, config_entry=None, discovery_data=None
):
"""Set up the MQTT Camera."""
- async_add_entities([MqttCamera(config, config_entry, discovery_hash)])
+ async_add_entities([MqttCamera(config, config_entry, discovery_data)])
class MqttCamera(MqttDiscoveryUpdate, MqttEntityDeviceInfo, Camera):
"""representation of a MQTT camera."""
- def __init__(self, config, config_entry, discovery_hash):
+ def __init__(self, config, config_entry, discovery_data):
"""Initialize the MQTT Camera."""
self._config = config
self._unique_id = config.get(CONF_UNIQUE_ID)
@@ -85,7 +84,7 @@ class MqttCamera(MqttDiscoveryUpdate, MqttEntityDeviceInfo, Camera):
device_config = config.get(CONF_DEVICE)
Camera.__init__(self)
- MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update)
+ MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update)
MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
async def async_added_to_hass(self):
@@ -127,6 +126,7 @@ class MqttCamera(MqttDiscoveryUpdate, MqttEntityDeviceInfo, Camera):
self._sub_state = await subscription.async_unsubscribe_topics(
self.hass, self._sub_state
)
+ await MqttDiscoveryUpdate.async_will_remove_from_hass(self)
async def async_camera_image(self):
"""Return image response."""
diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py
index 91a36a310cb..46404de0c8a 100644
--- a/homeassistant/components/mqtt/climate.py
+++ b/homeassistant/components/mqtt/climate.py
@@ -243,15 +243,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def async_discover(discovery_payload):
"""Discover and add a MQTT climate device."""
+ discovery_data = discovery_payload.discovery_data
try:
- discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
config = PLATFORM_SCHEMA(discovery_payload)
await _async_setup_entity(
- hass, config, async_add_entities, config_entry, discovery_hash
+ hass, config, async_add_entities, config_entry, discovery_data
)
except Exception:
- if discovery_hash:
- clear_discovery_hash(hass, discovery_hash)
+ clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH])
raise
async_dispatcher_connect(
@@ -260,10 +259,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def _async_setup_entity(
- hass, config, async_add_entities, config_entry=None, discovery_hash=None
+ hass, config, async_add_entities, config_entry=None, discovery_data=None
):
"""Set up the MQTT climate devices."""
- async_add_entities([MqttClimate(hass, config, config_entry, discovery_hash)])
+ async_add_entities([MqttClimate(hass, config, config_entry, discovery_data)])
class MqttClimate(
@@ -275,7 +274,7 @@ class MqttClimate(
):
"""Representation of an MQTT climate device."""
- def __init__(self, hass, config, config_entry, discovery_hash):
+ def __init__(self, hass, config, config_entry, discovery_data):
"""Initialize the climate device."""
self._config = config
self._unique_id = config.get(CONF_UNIQUE_ID)
@@ -303,7 +302,7 @@ class MqttClimate(
MqttAttributes.__init__(self, config)
MqttAvailability.__init__(self, config)
- MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update)
+ MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update)
MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
async def async_added_to_hass(self):
@@ -552,6 +551,7 @@ class MqttClimate(
)
await MqttAttributes.async_will_remove_from_hass(self)
await MqttAvailability.async_will_remove_from_hass(self)
+ await MqttDiscoveryUpdate.async_will_remove_from_hass(self)
@property
def should_poll(self):
diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py
index 3234bebbfc1..6044ec2af6e 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_TOPIC = "discovery_topic"
CONF_STATE_TOPIC = "state_topic"
PROTOCOL_311 = "3.1.1"
DEFAULT_QOS = 0
diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py
index 885343b7090..a7a39678192 100644
--- a/homeassistant/components/mqtt/cover.py
+++ b/homeassistant/components/mqtt/cover.py
@@ -178,14 +178,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def async_discover(discovery_payload):
"""Discover and add an MQTT cover."""
- discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
+ discovery_data = discovery_payload.discovery_data
try:
config = PLATFORM_SCHEMA(discovery_payload)
await _async_setup_entity(
- config, async_add_entities, config_entry, discovery_hash
+ config, async_add_entities, config_entry, discovery_data
)
except Exception:
- clear_discovery_hash(hass, discovery_hash)
+ clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH])
raise
async_dispatcher_connect(
@@ -194,10 +194,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def _async_setup_entity(
- config, async_add_entities, config_entry=None, discovery_hash=None
+ config, async_add_entities, config_entry=None, discovery_data=None
):
"""Set up the MQTT Cover."""
- async_add_entities([MqttCover(config, config_entry, discovery_hash)])
+ async_add_entities([MqttCover(config, config_entry, discovery_data)])
class MqttCover(
@@ -209,7 +209,7 @@ class MqttCover(
):
"""Representation of a cover that can be controlled using MQTT."""
- def __init__(self, config, config_entry, discovery_hash):
+ def __init__(self, config, config_entry, discovery_data):
"""Initialize the cover."""
self._unique_id = config.get(CONF_UNIQUE_ID)
self._position = None
@@ -227,7 +227,7 @@ class MqttCover(
MqttAttributes.__init__(self, config)
MqttAvailability.__init__(self, config)
- MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update)
+ MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update)
MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
async def async_added_to_hass(self):
@@ -369,6 +369,7 @@ class MqttCover(
)
await MqttAttributes.async_will_remove_from_hass(self)
await MqttAvailability.async_will_remove_from_hass(self)
+ await MqttDiscoveryUpdate.async_will_remove_from_hass(self)
@property
def should_poll(self):
diff --git a/homeassistant/components/mqtt/device_automation.py b/homeassistant/components/mqtt/device_automation.py
index 3f0889875d0..4fcfd8f66f2 100644
--- a/homeassistant/components/mqtt/device_automation.py
+++ b/homeassistant/components/mqtt/device_automation.py
@@ -4,6 +4,7 @@ import logging
import voluptuous as vol
from homeassistant.components import mqtt
+from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import ATTR_DISCOVERY_HASH, device_trigger
@@ -25,20 +26,26 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend(
async def async_setup_entry(hass, config_entry):
"""Set up MQTT device automation dynamically through MQTT discovery."""
+ async def async_device_removed(event):
+ """Handle the removal of a device."""
+ if event.data["action"] != "remove":
+ return
+ await device_trigger.async_device_removed(hass, event.data["device_id"])
+
async def async_discover(discovery_payload):
"""Discover and add an MQTT device automation."""
- discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
+ discovery_data = discovery_payload.discovery_data
try:
config = PLATFORM_SCHEMA(discovery_payload)
if config[CONF_AUTOMATION_TYPE] == AUTOMATION_TYPE_TRIGGER:
await device_trigger.async_setup_trigger(
- hass, config, config_entry, discovery_hash
+ hass, config, config_entry, discovery_data
)
except Exception:
- if discovery_hash:
- clear_discovery_hash(hass, discovery_hash)
+ clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH])
raise
async_dispatcher_connect(
hass, MQTT_DISCOVERY_NEW.format("device_automation", "mqtt"), async_discover
)
+ hass.bus.async_listen(EVENT_DEVICE_REGISTRY_UPDATED, async_device_removed)
diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py
index 2149024266d..5bb5ccbd9d4 100644
--- a/homeassistant/components/mqtt/device_trigger.py
+++ b/homeassistant/components/mqtt/device_trigger.py
@@ -1,6 +1,6 @@
"""Provides device automations for MQTT."""
import logging
-from typing import List
+from typing import Callable, List
import attr
import voluptuous as vol
@@ -18,6 +18,7 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from . import (
ATTR_DISCOVERY_HASH,
+ ATTR_DISCOVERY_TOPIC,
CONF_CONNECTIONS,
CONF_DEVICE,
CONF_IDENTIFIERS,
@@ -99,9 +100,11 @@ class Trigger:
"""Device trigger settings."""
device_id = attr.ib(type=str)
+ discovery_data = attr.ib(type=dict)
hass = attr.ib(type=HomeAssistantType)
payload = attr.ib(type=str)
qos = attr.ib(type=int)
+ remove_signal = attr.ib(type=Callable[[], None])
subtype = attr.ib(type=str)
topic = attr.ib(type=str)
type = attr.ib(type=str)
@@ -128,8 +131,9 @@ class Trigger:
return async_remove
- async def update_trigger(self, config):
+ async def update_trigger(self, config, discovery_hash, remove_signal):
"""Update MQTT device trigger."""
+ self.remove_signal = remove_signal
self.type = config[CONF_TYPE]
self.subtype = config[CONF_SUBTYPE]
self.topic = config[CONF_TOPIC]
@@ -143,8 +147,8 @@ class Trigger:
def detach_trigger(self):
"""Remove MQTT device trigger."""
# Mark trigger as unknown
-
self.topic = None
+
# Unsubscribe if this trigger is in use
for trig in self.trigger_instances:
if trig.remove:
@@ -163,9 +167,10 @@ async def _update_device(hass, config_entry, config):
device_registry.async_get_or_create(**device_info)
-async def async_setup_trigger(hass, config, config_entry, discovery_hash):
+async def async_setup_trigger(hass, config, config_entry, discovery_data):
"""Set up the MQTT device trigger."""
config = TRIGGER_DISCOVERY_SCHEMA(config)
+ discovery_hash = discovery_data[ATTR_DISCOVERY_HASH]
discovery_id = discovery_hash[1]
remove_signal = None
@@ -185,11 +190,10 @@ async def async_setup_trigger(hass, config, config_entry, discovery_hash):
else:
# Non-empty payload: Update trigger
_LOGGER.info("Updating trigger: %s", discovery_hash)
- payload.pop(ATTR_DISCOVERY_HASH)
config = TRIGGER_DISCOVERY_SCHEMA(payload)
await _update_device(hass, config_entry, config)
device_trigger = hass.data[DEVICE_TRIGGERS][discovery_id]
- await device_trigger.update_trigger(config)
+ await device_trigger.update_trigger(config, discovery_hash, remove_signal)
remove_signal = async_dispatcher_connect(
hass, MQTT_DISCOVERY_UPDATED.format(discovery_hash), discovery_update
@@ -212,14 +216,35 @@ async def async_setup_trigger(hass, config, config_entry, discovery_hash):
hass.data[DEVICE_TRIGGERS][discovery_id] = Trigger(
hass=hass,
device_id=device.id,
+ discovery_data=discovery_data,
type=config[CONF_TYPE],
subtype=config[CONF_SUBTYPE],
topic=config[CONF_TOPIC],
payload=config[CONF_PAYLOAD],
qos=config[CONF_QOS],
+ remove_signal=remove_signal,
)
else:
- await hass.data[DEVICE_TRIGGERS][discovery_id].update_trigger(config)
+ await hass.data[DEVICE_TRIGGERS][discovery_id].update_trigger(
+ config, discovery_hash, remove_signal
+ )
+
+
+async def async_device_removed(hass: HomeAssistant, device_id: str):
+ """Handle the removal of a device."""
+ triggers = await async_get_triggers(hass, device_id)
+ for trig in triggers:
+ device_trigger = hass.data[DEVICE_TRIGGERS].pop(trig[CONF_DISCOVERY_ID])
+ if device_trigger:
+ discovery_hash = device_trigger.discovery_data[ATTR_DISCOVERY_HASH]
+ discovery_topic = device_trigger.discovery_data[ATTR_DISCOVERY_TOPIC]
+
+ device_trigger.detach_trigger()
+ clear_discovery_hash(hass, discovery_hash)
+ device_trigger.remove_signal()
+ mqtt.publish(
+ hass, discovery_topic, "", retain=True,
+ )
async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]:
@@ -262,6 +287,8 @@ async def async_attach_trigger(
hass.data[DEVICE_TRIGGERS][discovery_id] = Trigger(
hass=hass,
device_id=device_id,
+ discovery_data=None,
+ remove_signal=None,
type=config[CONF_TYPE],
subtype=config[CONF_SUBTYPE],
topic=None,
diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py
index 418f648564d..e6350179571 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, CONF_STATE_TOPIC
+from .const import ATTR_DISCOVERY_HASH, ATTR_DISCOVERY_TOPIC, CONF_STATE_TOPIC
_LOGGER = logging.getLogger(__name__)
@@ -126,9 +126,9 @@ async def async_start(
for key, value in payload.items():
if isinstance(value, str) and value:
if value[0] == TOPIC_BASE and key.endswith("_topic"):
- payload[key] = "{}{}".format(base, value[1:])
+ payload[key] = f"{base}{value[1:]}"
if value[-1] == TOPIC_BASE and key.endswith("_topic"):
- payload[key] = "{}{}".format(value[:-1], base)
+ payload[key] = f"{value[:-1]}{base}"
# If present, the node_id will be included in the discovered object id
discovery_id = " ".join((node_id, object_id)) if node_id else object_id
@@ -137,6 +137,11 @@ async def async_start(
if payload:
# Attach MQTT topic to the payload, used for debug prints
setattr(payload, "__configuration_source__", f"MQTT (topic: '{topic}')")
+ discovery_data = {
+ ATTR_DISCOVERY_HASH: discovery_hash,
+ ATTR_DISCOVERY_TOPIC: topic,
+ }
+ setattr(payload, "discovery_data", discovery_data)
if CONF_PLATFORM in payload and "schema" not in payload:
platform = payload[CONF_PLATFORM]
@@ -158,12 +163,10 @@ async def async_start(
and component in IMPLICIT_STATE_TOPIC_COMPONENTS
):
# state_topic not specified, infer from discovery topic
- payload[CONF_STATE_TOPIC] = "{}/{}/{}{}/state".format(
- discovery_topic,
- component,
- "%s/" % node_id if node_id else "",
- object_id,
- )
+ 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",
@@ -173,8 +176,6 @@ async def async_start(
topic,
)
- payload[ATTR_DISCOVERY_HASH] = discovery_hash
-
if ALREADY_DISCOVERED not in hass.data:
hass.data[ALREADY_DISCOVERED] = {}
if discovery_hash in hass.data[ALREADY_DISCOVERED]:
@@ -196,7 +197,7 @@ async def async_start(
await async_load_platform(hass, component, "mqtt", payload, hass_config)
return
- config_entries_key = "{}.{}".format(component, "mqtt")
+ config_entries_key = f"{component}.mqtt"
async with hass.data[DATA_CONFIG_ENTRY_LOCK]:
if config_entries_key not in hass.data[CONFIG_ENTRY_IS_SETUP]:
if component == "device_automation":
diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py
index 07cb711ebd0..b50bdf9734b 100644
--- a/homeassistant/components/mqtt/fan.py
+++ b/homeassistant/components/mqtt/fan.py
@@ -118,15 +118,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def async_discover(discovery_payload):
"""Discover and add a MQTT fan."""
+ discovery_data = discovery_payload.discovery_data
try:
- discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
config = PLATFORM_SCHEMA(discovery_payload)
await _async_setup_entity(
- config, async_add_entities, config_entry, discovery_hash
+ config, async_add_entities, config_entry, discovery_data
)
except Exception:
- if discovery_hash:
- clear_discovery_hash(hass, discovery_hash)
+ clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH])
raise
async_dispatcher_connect(
@@ -135,13 +134,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def _async_setup_entity(
- config, async_add_entities, config_entry=None, discovery_hash=None
+ config, async_add_entities, config_entry=None, discovery_data=None
):
"""Set up the MQTT fan."""
- async_add_entities([MqttFan(config, config_entry, discovery_hash)])
+ async_add_entities([MqttFan(config, config_entry, discovery_data)])
-# pylint: disable=too-many-ancestors
class MqttFan(
MqttAttributes,
MqttAvailability,
@@ -151,7 +149,7 @@ class MqttFan(
):
"""A MQTT fan component."""
- def __init__(self, config, config_entry, discovery_hash):
+ def __init__(self, config, config_entry, discovery_data):
"""Initialize the MQTT fan."""
self._unique_id = config.get(CONF_UNIQUE_ID)
self._state = False
@@ -174,7 +172,7 @@ class MqttFan(
MqttAttributes.__init__(self, config)
MqttAvailability.__init__(self, config)
- MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update)
+ MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update)
MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
async def async_added_to_hass(self):
@@ -318,6 +316,7 @@ class MqttFan(
)
await MqttAttributes.async_will_remove_from_hass(self)
await MqttAvailability.async_will_remove_from_hass(self)
+ await MqttDiscoveryUpdate.async_will_remove_from_hass(self)
@property
def should_poll(self):
diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py
index a72008c059f..d48b4ae4762 100644
--- a/homeassistant/components/mqtt/light/__init__.py
+++ b/homeassistant/components/mqtt/light/__init__.py
@@ -1,9 +1,4 @@
-"""
-Support for MQTT lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.mqtt/
-"""
+"""Support for MQTT lights."""
import logging
import voluptuous as vol
@@ -52,15 +47,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def async_discover(discovery_payload):
"""Discover and add a MQTT light."""
+ discovery_data = discovery_payload.discovery_data
try:
- discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
config = PLATFORM_SCHEMA(discovery_payload)
await _async_setup_entity(
- config, async_add_entities, config_entry, discovery_hash
+ config, async_add_entities, config_entry, discovery_data
)
except Exception:
- if discovery_hash:
- clear_discovery_hash(hass, discovery_hash)
+ clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH])
raise
async_dispatcher_connect(
@@ -69,7 +63,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def _async_setup_entity(
- config, async_add_entities, config_entry=None, discovery_hash=None
+ config, async_add_entities, config_entry=None, discovery_data=None
):
"""Set up a MQTT Light."""
setup_entity = {
@@ -78,5 +72,5 @@ async def _async_setup_entity(
"template": async_setup_entity_template,
}
await setup_entity[config[CONF_SCHEMA]](
- config, async_add_entities, config_entry, discovery_hash
+ config, async_add_entities, config_entry, discovery_data
)
diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py
index ff57db7c8c1..4b47014af48 100644
--- a/homeassistant/components/mqtt/light/schema_basic.py
+++ b/homeassistant/components/mqtt/light/schema_basic.py
@@ -1,9 +1,4 @@
-"""
-Support for MQTT lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.mqtt/
-"""
+"""Support for MQTT lights."""
import logging
import voluptuous as vol
@@ -151,15 +146,14 @@ PLATFORM_SCHEMA_BASIC = (
async def async_setup_entity_basic(
- config, async_add_entities, config_entry, discovery_hash=None
+ config, async_add_entities, config_entry, discovery_data=None
):
"""Set up a MQTT Light."""
config.setdefault(CONF_STATE_VALUE_TEMPLATE, config.get(CONF_VALUE_TEMPLATE))
- async_add_entities([MqttLight(config, config_entry, discovery_hash)])
+ async_add_entities([MqttLight(config, config_entry, discovery_data)])
-# pylint: disable=too-many-ancestors
class MqttLight(
MqttAttributes,
MqttAvailability,
@@ -170,7 +164,7 @@ class MqttLight(
):
"""Representation of a MQTT light."""
- def __init__(self, config, config_entry, discovery_hash):
+ def __init__(self, config, config_entry, discovery_data):
"""Initialize MQTT light."""
self._state = False
self._sub_state = None
@@ -200,7 +194,7 @@ class MqttLight(
MqttAttributes.__init__(self, config)
MqttAvailability.__init__(self, config)
- MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update)
+ MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update)
MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
async def async_added_to_hass(self):
@@ -541,6 +535,7 @@ class MqttLight(
)
await MqttAttributes.async_will_remove_from_hass(self)
await MqttAvailability.async_will_remove_from_hass(self)
+ await MqttDiscoveryUpdate.async_will_remove_from_hass(self)
@property
def brightness(self):
@@ -680,7 +675,7 @@ class MqttLight(
{"red": rgb[0], "green": rgb[1], "blue": rgb[2]}
)
else:
- rgb_color_str = "{},{},{}".format(*rgb)
+ rgb_color_str = f"{rgb[0]},{rgb[1]},{rgb[2]}"
mqtt.async_publish(
self.hass,
@@ -700,7 +695,7 @@ class MqttLight(
mqtt.async_publish(
self.hass,
self._topic[CONF_HS_COMMAND_TOPIC],
- "{},{}".format(*hs_color),
+ f"{hs_color[0]},{hs_color[1]}",
self._config[CONF_QOS],
self._config[CONF_RETAIN],
)
@@ -715,7 +710,7 @@ class MqttLight(
mqtt.async_publish(
self.hass,
self._topic[CONF_XY_COMMAND_TOPIC],
- "{},{}".format(*xy_color),
+ f"{xy_color[0]},{xy_color[1]}",
self._config[CONF_QOS],
self._config[CONF_RETAIN],
)
@@ -758,7 +753,7 @@ class MqttLight(
{"red": rgb[0], "green": rgb[1], "blue": rgb[2]}
)
else:
- rgb_color_str = "{},{},{}".format(*rgb)
+ rgb_color_str = f"{rgb[0]},{rgb[1]},{rgb[2]}"
mqtt.async_publish(
self.hass,
diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py
index c4de1edbc3c..60ecf80fb63 100644
--- a/homeassistant/components/mqtt/light/schema_json.py
+++ b/homeassistant/components/mqtt/light/schema_json.py
@@ -1,9 +1,4 @@
-"""
-Support for MQTT JSON lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.mqtt_json/
-"""
+"""Support for MQTT JSON lights."""
import json
import logging
@@ -124,13 +119,12 @@ PLATFORM_SCHEMA_JSON = (
async def async_setup_entity_json(
- config: ConfigType, async_add_entities, config_entry, discovery_hash
+ config: ConfigType, async_add_entities, config_entry, discovery_data
):
"""Set up a MQTT JSON Light."""
- async_add_entities([MqttLightJson(config, config_entry, discovery_hash)])
+ async_add_entities([MqttLightJson(config, config_entry, discovery_data)])
-# pylint: disable=too-many-ancestors
class MqttLightJson(
MqttAttributes,
MqttAvailability,
@@ -141,7 +135,7 @@ class MqttLightJson(
):
"""Representation of a MQTT JSON light."""
- def __init__(self, config, config_entry, discovery_hash):
+ def __init__(self, config, config_entry, discovery_data):
"""Initialize MQTT JSON light."""
self._state = False
self._sub_state = None
@@ -164,7 +158,7 @@ class MqttLightJson(
MqttAttributes.__init__(self, config)
MqttAvailability.__init__(self, config)
- MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update)
+ MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update)
MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
async def async_added_to_hass(self):
@@ -352,6 +346,7 @@ class MqttLightJson(
)
await MqttAttributes.async_will_remove_from_hass(self)
await MqttAvailability.async_will_remove_from_hass(self)
+ await MqttDiscoveryUpdate.async_will_remove_from_hass(self)
@property
def brightness(self):
diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py
index dd69a8e87d6..853e7f4411f 100644
--- a/homeassistant/components/mqtt/light/schema_template.py
+++ b/homeassistant/components/mqtt/light/schema_template.py
@@ -1,9 +1,4 @@
-"""
-Support for MQTT Template lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.mqtt_template/
-"""
+"""Support for MQTT Template lights."""
import logging
import voluptuous as vol
@@ -98,13 +93,12 @@ PLATFORM_SCHEMA_TEMPLATE = (
async def async_setup_entity_template(
- config, async_add_entities, config_entry, discovery_hash
+ config, async_add_entities, config_entry, discovery_data
):
"""Set up a MQTT Template light."""
- async_add_entities([MqttTemplate(config, config_entry, discovery_hash)])
+ async_add_entities([MqttTemplate(config, config_entry, discovery_data)])
-# pylint: disable=too-many-ancestors
class MqttTemplate(
MqttAttributes,
MqttAvailability,
@@ -115,7 +109,7 @@ class MqttTemplate(
):
"""Representation of a MQTT Template light."""
- def __init__(self, config, config_entry, discovery_hash):
+ def __init__(self, config, config_entry, discovery_data):
"""Initialize a MQTT Template light."""
self._state = False
self._sub_state = None
@@ -139,7 +133,7 @@ class MqttTemplate(
MqttAttributes.__init__(self, config)
MqttAvailability.__init__(self, config)
- MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update)
+ MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update)
MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
async def async_added_to_hass(self):
@@ -329,6 +323,7 @@ class MqttTemplate(
)
await MqttAttributes.async_will_remove_from_hass(self)
await MqttAvailability.async_will_remove_from_hass(self)
+ await MqttDiscoveryUpdate.async_will_remove_from_hass(self)
@property
def brightness(self):
diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py
index 6910e955288..89f005b7469 100644
--- a/homeassistant/components/mqtt/lock.py
+++ b/homeassistant/components/mqtt/lock.py
@@ -80,15 +80,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def async_discover(discovery_payload):
"""Discover and add an MQTT lock."""
+ discovery_data = discovery_payload.discovery_data
try:
- discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
config = PLATFORM_SCHEMA(discovery_payload)
await _async_setup_entity(
- config, async_add_entities, config_entry, discovery_hash
+ config, async_add_entities, config_entry, discovery_data
)
except Exception:
- if discovery_hash:
- clear_discovery_hash(hass, discovery_hash)
+ clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH])
raise
async_dispatcher_connect(
@@ -97,10 +96,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def _async_setup_entity(
- config, async_add_entities, config_entry=None, discovery_hash=None
+ config, async_add_entities, config_entry=None, discovery_data=None
):
"""Set up the MQTT Lock platform."""
- async_add_entities([MqttLock(config, config_entry, discovery_hash)])
+ async_add_entities([MqttLock(config, config_entry, discovery_data)])
class MqttLock(
@@ -112,7 +111,7 @@ class MqttLock(
):
"""Representation of a lock that can be toggled using MQTT."""
- def __init__(self, config, config_entry, discovery_hash):
+ def __init__(self, config, config_entry, discovery_data):
"""Initialize the lock."""
self._unique_id = config.get(CONF_UNIQUE_ID)
self._state = False
@@ -126,7 +125,7 @@ class MqttLock(
MqttAttributes.__init__(self, config)
MqttAvailability.__init__(self, config)
- MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update)
+ MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update)
MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
async def async_added_to_hass(self):
@@ -192,6 +191,7 @@ class MqttLock(
)
await MqttAttributes.async_will_remove_from_hass(self)
await MqttAvailability.async_will_remove_from_hass(self)
+ await MqttDiscoveryUpdate.async_will_remove_from_hass(self)
@property
def should_poll(self):
diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py
index 967a434c9d5..07910697d21 100644
--- a/homeassistant/components/mqtt/sensor.py
+++ b/homeassistant/components/mqtt/sensor.py
@@ -76,15 +76,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def async_discover_sensor(discovery_payload):
"""Discover and add a discovered MQTT sensor."""
+ discovery_data = discovery_payload.discovery_data
try:
- discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
config = PLATFORM_SCHEMA(discovery_payload)
await _async_setup_entity(
- config, async_add_entities, config_entry, discovery_hash
+ config, async_add_entities, config_entry, discovery_data
)
except Exception:
- if discovery_hash:
- clear_discovery_hash(hass, discovery_hash)
+ clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH])
raise
async_dispatcher_connect(
@@ -93,10 +92,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def _async_setup_entity(
- config: ConfigType, async_add_entities, config_entry=None, discovery_hash=None
+ config: ConfigType, async_add_entities, config_entry=None, discovery_data=None
):
"""Set up MQTT sensor."""
- async_add_entities([MqttSensor(config, config_entry, discovery_hash)])
+ async_add_entities([MqttSensor(config, config_entry, discovery_data)])
class MqttSensor(
@@ -104,7 +103,7 @@ class MqttSensor(
):
"""Representation of a sensor that can be updated using MQTT."""
- def __init__(self, config, config_entry, discovery_hash):
+ def __init__(self, config, config_entry, discovery_data):
"""Initialize the sensor."""
self._config = config
self._unique_id = config.get(CONF_UNIQUE_ID)
@@ -123,7 +122,7 @@ class MqttSensor(
MqttAttributes.__init__(self, config)
MqttAvailability.__init__(self, config)
- MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update)
+ MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update)
MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
async def async_added_to_hass(self):
@@ -208,6 +207,7 @@ class MqttSensor(
)
await MqttAttributes.async_will_remove_from_hass(self)
await MqttAvailability.async_will_remove_from_hass(self)
+ await MqttDiscoveryUpdate.async_will_remove_from_hass(self)
@callback
def value_is_expired(self, *_):
diff --git a/homeassistant/components/mqtt/server.py b/homeassistant/components/mqtt/server.py
index 61ba5c392b1..1b2a56a2195 100644
--- a/homeassistant/components/mqtt/server.py
+++ b/homeassistant/components/mqtt/server.py
@@ -85,9 +85,7 @@ def generate_config(hass, passwd, password):
# Encrypt with what hbmqtt uses to verify
passwd.write(
- "homeassistant:{}\n".format(custom_app_context.encrypt(password)).encode(
- "utf-8"
- )
+ f"homeassistant:{custom_app_context.encrypt(password)}\n".encode("utf-8")
)
passwd.flush()
diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py
index 3c35434be86..32066c67b7a 100644
--- a/homeassistant/components/mqtt/switch.py
+++ b/homeassistant/components/mqtt/switch.py
@@ -76,15 +76,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def async_discover(discovery_payload):
"""Discover and add a MQTT switch."""
+ discovery_data = discovery_payload.discovery_data
try:
- discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
config = PLATFORM_SCHEMA(discovery_payload)
await _async_setup_entity(
- config, async_add_entities, config_entry, discovery_hash
+ config, async_add_entities, config_entry, discovery_data
)
except Exception:
- if discovery_hash:
- clear_discovery_hash(hass, discovery_hash)
+ clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH])
raise
async_dispatcher_connect(
@@ -93,13 +92,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def _async_setup_entity(
- config, async_add_entities, config_entry=None, discovery_hash=None
+ config, async_add_entities, config_entry=None, discovery_data=None
):
"""Set up the MQTT switch."""
- async_add_entities([MqttSwitch(config, config_entry, discovery_hash)])
+ async_add_entities([MqttSwitch(config, config_entry, discovery_data)])
-# pylint: disable=too-many-ancestors
class MqttSwitch(
MqttAttributes,
MqttAvailability,
@@ -110,7 +108,7 @@ class MqttSwitch(
):
"""Representation of a switch that can be toggled using MQTT."""
- def __init__(self, config, config_entry, discovery_hash):
+ def __init__(self, config, config_entry, discovery_data):
"""Initialize the MQTT switch."""
self._state = False
self._sub_state = None
@@ -127,7 +125,7 @@ class MqttSwitch(
MqttAttributes.__init__(self, config)
MqttAvailability.__init__(self, config)
- MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update)
+ MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update)
MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
async def async_added_to_hass(self):
@@ -204,6 +202,7 @@ class MqttSwitch(
)
await MqttAttributes.async_will_remove_from_hass(self)
await MqttAvailability.async_will_remove_from_hass(self)
+ await MqttDiscoveryUpdate.async_will_remove_from_hass(self)
@property
def should_poll(self):
diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py
index 84f564e5c7e..b16ec7aaf74 100644
--- a/homeassistant/components/mqtt/vacuum/__init__.py
+++ b/homeassistant/components/mqtt/vacuum/__init__.py
@@ -1,9 +1,4 @@
-"""
-Support for MQTT vacuums.
-
-For more details about this platform, please refer to the documentation at
-https://www.home-assistant.io/components/vacuum.mqtt/
-"""
+"""Support for MQTT vacuums."""
import logging
import voluptuous as vol
@@ -44,15 +39,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def async_discover(discovery_payload):
"""Discover and add a MQTT vacuum."""
+ discovery_data = discovery_payload.discovery_data
try:
- discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
config = PLATFORM_SCHEMA(discovery_payload)
await _async_setup_entity(
- config, async_add_entities, config_entry, discovery_hash
+ config, async_add_entities, config_entry, discovery_data
)
except Exception:
- if discovery_hash:
- clear_discovery_hash(hass, discovery_hash)
+ clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH])
raise
async_dispatcher_connect(
@@ -61,10 +55,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def _async_setup_entity(
- config, async_add_entities, config_entry, discovery_hash=None
+ config, async_add_entities, config_entry, discovery_data=None
):
"""Set up the MQTT vacuum."""
setup_entity = {LEGACY: async_setup_entity_legacy, STATE: async_setup_entity_state}
await setup_entity[config[CONF_SCHEMA]](
- config, async_add_entities, config_entry, discovery_hash
+ config, async_add_entities, config_entry, discovery_data
)
diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py
index 6c08b18bc9c..7679b97d62e 100644
--- a/homeassistant/components/mqtt/vacuum/schema_legacy.py
+++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py
@@ -162,13 +162,12 @@ PLATFORM_SCHEMA_LEGACY = (
async def async_setup_entity_legacy(
- config, async_add_entities, config_entry, discovery_hash
+ config, async_add_entities, config_entry, discovery_data
):
"""Set up a MQTT Vacuum Legacy."""
- async_add_entities([MqttVacuum(config, config_entry, discovery_hash)])
+ async_add_entities([MqttVacuum(config, config_entry, discovery_data)])
-# pylint: disable=too-many-ancestors
class MqttVacuum(
MqttAttributes,
MqttAvailability,
@@ -267,9 +266,12 @@ class MqttVacuum(
async def async_will_remove_from_hass(self):
"""Unsubscribe when removed."""
- await subscription.async_unsubscribe_topics(self.hass, self._sub_state)
+ 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 _subscribe_topics(self):
"""(Re)Subscribe to topics."""
diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py
index 9dd5053d019..126a2432cb0 100644
--- a/homeassistant/components/mqtt/vacuum/schema_state.py
+++ b/homeassistant/components/mqtt/vacuum/schema_state.py
@@ -157,13 +157,12 @@ PLATFORM_SCHEMA_STATE = (
async def async_setup_entity_state(
- config, async_add_entities, config_entry, discovery_hash
+ config, async_add_entities, config_entry, discovery_data
):
"""Set up a State MQTT Vacuum."""
- async_add_entities([MqttStateVacuum(config, config_entry, discovery_hash)])
+ async_add_entities([MqttStateVacuum(config, config_entry, discovery_data)])
-# pylint: disable=too-many-ancestors
class MqttStateVacuum(
MqttAttributes,
MqttAvailability,
@@ -232,9 +231,12 @@ class MqttStateVacuum(
async def async_will_remove_from_hass(self):
"""Unsubscribe when removed."""
- await subscription.async_unsubscribe_topics(self.hass, self._sub_state)
+ 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 _subscribe_topics(self):
"""(Re)Subscribe to topics."""
diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py
index d8dfa65f799..580ffd606f3 100644
--- a/homeassistant/components/mqtt_room/sensor.py
+++ b/homeassistant/components/mqtt_room/sensor.py
@@ -73,7 +73,7 @@ class MQTTRoomSensor(Entity):
"""Initialize the sensor."""
self._state = STATE_NOT_HOME
self._name = name
- self._state_topic = "{}{}".format(state_topic, "/+")
+ self._state_topic = f"{state_topic}/+"
self._device_id = slugify(device_id).upper()
self._timeout = timeout
self._consider_home = (
diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py
index da1db0e02aa..2ceca024a6f 100644
--- a/homeassistant/components/mvglive/sensor.py
+++ b/homeassistant/components/mvglive/sensor.py
@@ -7,7 +7,7 @@ import MVGLive
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -131,7 +131,7 @@ class MVGLiveSensor(Entity):
@property
def unit_of_measurement(self):
"""Return the unit this state is expressed in."""
- return "min"
+ return TIME_MINUTES
def update(self):
"""Get the latest data and update the state."""
diff --git a/homeassistant/components/mychevy/binary_sensor.py b/homeassistant/components/mychevy/binary_sensor.py
index e6d4d23c9b4..e5b0dc8b6ea 100644
--- a/homeassistant/components/mychevy/binary_sensor.py
+++ b/homeassistant/components/mychevy/binary_sensor.py
@@ -1,7 +1,10 @@
"""Support for MyChevy binary sensors."""
import logging
-from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorDevice
+from homeassistant.components.binary_sensor import (
+ DOMAIN as BINARY_SENSOR_DOMAIN,
+ BinarySensorDevice,
+)
from homeassistant.core import callback
from homeassistant.util import slugify
@@ -42,11 +45,7 @@ class EVBinarySensor(BinarySensorDevice):
self._type = config.device_class
self._is_on = None
self._car_vid = car_vid
- self.entity_id = ENTITY_ID_FORMAT.format(
- "{}_{}_{}".format(
- MYCHEVY_DOMAIN, slugify(self._car.name), slugify(self._name)
- )
- )
+ self.entity_id = f"{BINARY_SENSOR_DOMAIN}.{MYCHEVY_DOMAIN}_{slugify(self._car.name)}_{slugify(self._name)}"
@property
def name(self):
diff --git a/homeassistant/components/mychevy/sensor.py b/homeassistant/components/mychevy/sensor.py
index 844c301fe49..f45c81a0007 100644
--- a/homeassistant/components/mychevy/sensor.py
+++ b/homeassistant/components/mychevy/sensor.py
@@ -1,7 +1,8 @@
"""Support for MyChevy sensors."""
import logging
-from homeassistant.components.sensor import ENTITY_ID_FORMAT
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.const import UNIT_PERCENTAGE
from homeassistant.core import callback
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.icon import icon_for_battery_level
@@ -25,7 +26,9 @@ SENSORS = [
EVSensorConfig("Electric Range", "electricRange", "miles", "mdi:speedometer"),
EVSensorConfig("Charged By", "estimatedFullChargeBy"),
EVSensorConfig("Charge Mode", "chargeMode"),
- EVSensorConfig("Battery Level", BATTERY_SENSOR, "%", "mdi:battery", ["charging"]),
+ EVSensorConfig(
+ "Battery Level", BATTERY_SENSOR, UNIT_PERCENTAGE, "mdi:battery", ["charging"]
+ ),
]
@@ -120,11 +123,7 @@ class EVSensor(Entity):
self._state_attributes = {}
self._car_vid = car_vid
- self.entity_id = ENTITY_ID_FORMAT.format(
- "{}_{}_{}".format(
- MYCHEVY_DOMAIN, slugify(self._car.name), slugify(self._name)
- )
- )
+ self.entity_id = f"{SENSOR_DOMAIN}.{MYCHEVY_DOMAIN}_{slugify(self._car.name)}_{slugify(self._name)}"
async def async_added_to_hass(self):
"""Register callbacks."""
diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py
index a528be15e14..43e398b142f 100644
--- a/homeassistant/components/mysensors/__init__.py
+++ b/homeassistant/components/mysensors/__init__.py
@@ -129,7 +129,7 @@ async def async_setup(hass, config):
def _get_mysensors_name(gateway, node_id, child_id):
"""Return a name for a node child."""
- node_name = "{} {}".format(gateway.sensors[node_id].sketch_name, node_id)
+ node_name = f"{gateway.sensors[node_id].sketch_name} {node_id}"
node_name = next(
(
node[CONF_NODE_NAME]
diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py
index 903ec069b51..d906c306dfc 100644
--- a/homeassistant/components/mysensors/gateway.py
+++ b/homeassistant/components/mysensors/gateway.py
@@ -42,7 +42,7 @@ MQTT_COMPONENT = "mqtt"
def is_serial_port(value):
"""Validate that value is a windows serial port or a unix device."""
if sys.platform.startswith("win"):
- ports = ("COM{}".format(idx + 1) for idx in range(256))
+ ports = (f"COM{idx + 1}" for idx in range(256))
if value in ports:
return value
raise vol.Invalid(f"{value} is not a serial port")
@@ -73,8 +73,7 @@ async def setup_gateways(hass, config):
for index, gateway_conf in enumerate(conf[CONF_GATEWAYS]):
persistence_file = gateway_conf.get(
- CONF_PERSISTENCE_FILE,
- hass.config.path("mysensors{}.pickle".format(index + 1)),
+ CONF_PERSISTENCE_FILE, hass.config.path(f"mysensors{index + 1}.pickle"),
)
ready_gateway = await _get_gateway(hass, config, gateway_conf, persistence_file)
if ready_gateway is not None:
diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py
index f0e9b06b762..20b266e550e 100644
--- a/homeassistant/components/mysensors/helpers.py
+++ b/homeassistant/components/mysensors/helpers.py
@@ -92,8 +92,8 @@ def invalid_msg(gateway, child, value_type_name):
"""Return a message for an invalid child during schema validation."""
pres = gateway.const.Presentation
set_req = gateway.const.SetReq
- return "{} requires value_type {}".format(
- pres(child.type).name, set_req[value_type_name].name
+ return (
+ f"{pres(child.type).name} requires value_type {set_req[value_type_name].name}"
)
diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py
index 45da4a77d5f..b25cf977d83 100644
--- a/homeassistant/components/mysensors/light.py
+++ b/homeassistant/components/mysensors/light.py
@@ -228,8 +228,6 @@ class MySensorsLightRGB(MySensorsLight):
class MySensorsLightRGBW(MySensorsLightRGB):
"""RGBW child class to MySensorsLightRGB."""
- # pylint: disable=too-many-ancestors
-
@property
def supported_features(self):
"""Flag supported features."""
diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py
index ddad451d20f..997728ed495 100644
--- a/homeassistant/components/mysensors/sensor.py
+++ b/homeassistant/components/mysensors/sensor.py
@@ -3,16 +3,18 @@ from homeassistant.components import mysensors
from homeassistant.components.sensor import DOMAIN
from homeassistant.const import (
ENERGY_KILO_WATT_HOUR,
+ MASS_KILOGRAMS,
POWER_WATT,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
)
SENSORS = {
"V_TEMP": [None, "mdi:thermometer"],
- "V_HUM": ["%", "mdi:water-percent"],
- "V_DIMMER": ["%", "mdi:percent"],
- "V_PERCENTAGE": ["%", "mdi:percent"],
+ "V_HUM": [UNIT_PERCENTAGE, "mdi:water-percent"],
+ "V_DIMMER": [UNIT_PERCENTAGE, "mdi:percent"],
+ "V_PERCENTAGE": [UNIT_PERCENTAGE, "mdi:percent"],
"V_PRESSURE": [None, "mdi:gauge"],
"V_FORECAST": [None, "mdi:weather-partly-cloudy"],
"V_RAIN": [None, "mdi:weather-rainy"],
@@ -20,12 +22,12 @@ SENSORS = {
"V_WIND": [None, "mdi:weather-windy"],
"V_GUST": [None, "mdi:weather-windy"],
"V_DIRECTION": ["°", "mdi:compass"],
- "V_WEIGHT": ["kg", "mdi:weight-kilogram"],
+ "V_WEIGHT": [MASS_KILOGRAMS, "mdi:weight-kilogram"],
"V_DISTANCE": ["m", "mdi:ruler"],
"V_IMPEDANCE": ["ohm", None],
"V_WATT": [POWER_WATT, None],
"V_KWH": [ENERGY_KILO_WATT_HOUR, None],
- "V_LIGHT_LEVEL": ["%", "mdi:white-balance-sunny"],
+ "V_LIGHT_LEVEL": [UNIT_PERCENTAGE, "mdi:white-balance-sunny"],
"V_FLOW": ["m", "mdi:gauge"],
"V_VOLUME": ["m³", None],
"V_LEVEL": {
diff --git a/homeassistant/components/neato/.translations/lv.json b/homeassistant/components/neato/.translations/lv.json
new file mode 100644
index 00000000000..26b0bcb7fd2
--- /dev/null
+++ b/homeassistant/components/neato/.translations/lv.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "Parole",
+ "username": "Lietot\u0101jv\u0101rds"
+ },
+ "title": "Neato konta inform\u0101cija"
+ }
+ },
+ "title": "Neato"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/neato/config_flow.py b/homeassistant/components/neato/config_flow.py
index 56fba9047e7..88a2085b339 100644
--- a/homeassistant/components/neato/config_flow.py
+++ b/homeassistant/components/neato/config_flow.py
@@ -12,7 +12,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
# pylint: disable=unused-import
from .const import CONF_VENDOR, NEATO_DOMAIN, VALID_VENDORS
-DOCS_URL = "https://www.home-assistant.io/components/neato"
+DOCS_URL = "https://www.home-assistant.io/integrations/neato"
DEFAULT_VENDOR = "neato"
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/neato/sensor.py b/homeassistant/components/neato/sensor.py
index 70d273fe690..5573e280a99 100644
--- a/homeassistant/components/neato/sensor.py
+++ b/homeassistant/components/neato/sensor.py
@@ -5,6 +5,7 @@ import logging
from pybotvac.exceptions import NeatoRobotException
from homeassistant.components.sensor import DEVICE_CLASS_BATTERY
+from homeassistant.const import UNIT_PERCENTAGE
from homeassistant.helpers.entity import Entity
from .const import NEATO_DOMAIN, NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
@@ -83,7 +84,7 @@ class NeatoSensor(Entity):
@property
def unit_of_measurement(self):
"""Return unit of measurement."""
- return "%"
+ return UNIT_PERCENTAGE
@property
def device_info(self):
diff --git a/homeassistant/components/nest/binary_sensor.py b/homeassistant/components/nest/binary_sensor.py
index 05170a54ed1..a029fcfe7d6 100644
--- a/homeassistant/components/nest/binary_sensor.py
+++ b/homeassistant/components/nest/binary_sensor.py
@@ -71,7 +71,7 @@ async def async_setup_entry(hass, entry, async_add_entities):
wstr = (
variable + " is no a longer supported "
"monitored_conditions. See "
- "https://home-assistant.io/components/binary_sensor.nest/ "
+ "https://www.home-assistant.io/integrations/binary_sensor.nest/ "
"for valid options."
)
_LOGGER.error(wstr)
diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py
index 6b1a198abbb..225caee0a90 100644
--- a/homeassistant/components/nest/sensor.py
+++ b/homeassistant/components/nest/sensor.py
@@ -8,6 +8,7 @@ from homeassistant.const import (
STATE_OFF,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
)
from . import CONF_SENSORS, DATA_NEST, DATA_NEST_CONFIG, NestSensorDevice
@@ -41,7 +42,7 @@ _VALID_SENSOR_TYPES = (
+ STRUCTURE_CAMERA_SENSOR_TYPES
)
-SENSOR_UNITS = {"humidity": "%"}
+SENSOR_UNITS = {"humidity": UNIT_PERCENTAGE}
SENSOR_DEVICE_CLASSES = {"humidity": DEVICE_CLASS_HUMIDITY}
@@ -90,14 +91,14 @@ async def async_setup_entry(hass, entry, async_add_entities):
if variable in DEPRECATED_WEATHER_VARS:
wstr = (
"Nest no longer provides weather data like %s. See "
- "https://home-assistant.io/components/#weather "
+ "https://www.home-assistant.io/integrations/#weather "
"for a list of other weather integrations to use." % variable
)
else:
wstr = (
variable + " is no a longer supported "
"monitored_conditions. See "
- "https://home-assistant.io/components/"
+ "https://www.home-assistant.io/integrations/"
"binary_sensor.nest/ for valid options."
)
_LOGGER.error(wstr)
diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py
index bd79f597b5b..776e63bef7d 100644
--- a/homeassistant/components/netatmo/__init__.py
+++ b/homeassistant/components/netatmo/__init__.py
@@ -1,27 +1,47 @@
"""The Netatmo integration."""
import asyncio
import logging
+import secrets
+import pyatmo
import voluptuous as vol
+from homeassistant.components import cloud
+from homeassistant.components.webhook import (
+ async_register as webhook_register,
+ async_unregister as webhook_unregister,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_DISCOVERY,
CONF_USERNAME,
+ CONF_WEBHOOK_ID,
+ EVENT_HOMEASSISTANT_START,
+ EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from . import api, config_flow
-from .const import AUTH, DATA_PERSONS, DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN
+from .const import (
+ AUTH,
+ CONF_CLOUDHOOK_URL,
+ DATA_PERSONS,
+ DOMAIN,
+ OAUTH2_AUTHORIZE,
+ OAUTH2_TOKEN,
+)
+from .webhook import handle_webhook
_LOGGER = logging.getLogger(__name__)
CONF_SECRET_KEY = "secret_key"
CONF_WEBHOOKS = "webhooks"
+WAIT_FOR_CLOUD = 5
+
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
@@ -38,7 +58,7 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
-PLATFORMS = ["binary_sensor", "camera", "climate", "sensor"]
+PLATFORMS = ["camera", "climate", "sensor"]
async def async_setup(hass: HomeAssistant, config: dict):
@@ -79,6 +99,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
hass.config_entries.async_forward_entry_setup(entry, component)
)
+ async def unregister_webhook(event):
+ _LOGGER.debug("Unregister Netatmo webhook (%s)", entry.data[CONF_WEBHOOK_ID])
+ webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
+
+ async def register_webhook(event):
+ # Wait for the could integration to be ready
+ await asyncio.sleep(WAIT_FOR_CLOUD)
+
+ if CONF_WEBHOOK_ID not in entry.data:
+ data = {**entry.data, CONF_WEBHOOK_ID: secrets.token_hex()}
+ hass.config_entries.async_update_entry(entry, data=data)
+
+ if hass.components.cloud.async_active_subscription():
+ if CONF_CLOUDHOOK_URL not in entry.data:
+ webhook_url = await hass.components.cloud.async_create_cloudhook(
+ entry.data[CONF_WEBHOOK_ID]
+ )
+ data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url}
+ hass.config_entries.async_update_entry(entry, data=data)
+ else:
+ webhook_url = entry.data[CONF_CLOUDHOOK_URL]
+ else:
+ webhook_url = hass.components.webhook.async_generate_url(
+ entry.data[CONF_WEBHOOK_ID]
+ )
+
+ try:
+ await hass.async_add_executor_job(
+ hass.data[DOMAIN][entry.entry_id][AUTH].addwebhook, webhook_url
+ )
+ webhook_register(
+ hass, DOMAIN, "Netatmo", entry.data[CONF_WEBHOOK_ID], handle_webhook
+ )
+ _LOGGER.info("Register Netatmo webhook: %s", webhook_url)
+ except pyatmo.ApiError as err:
+ _LOGGER.error("Error during webhook registration - %s", err)
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook)
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, register_webhook)
return True
@@ -95,4 +155,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
+ if CONF_WEBHOOK_ID in entry.data:
+ await hass.async_add_executor_job(
+ hass.data[DOMAIN][entry.entry_id][AUTH].dropwebhook()
+ )
+
return unload_ok
+
+
+async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Cleanup when entry is removed."""
+ if CONF_WEBHOOK_ID in entry.data:
+ try:
+ _LOGGER.debug(
+ "Removing Netatmo cloudhook (%s)", entry.data[CONF_WEBHOOK_ID]
+ )
+ await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID])
+ except cloud.CloudNotAvailable:
+ pass
diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py
deleted file mode 100644
index 5f419bda2c2..00000000000
--- a/homeassistant/components/netatmo/binary_sensor.py
+++ /dev/null
@@ -1,203 +0,0 @@
-"""Support for the Netatmo binary sensors."""
-import logging
-
-import pyatmo
-
-from homeassistant.components.binary_sensor import BinarySensorDevice
-
-from .camera import CameraData
-from .const import AUTH, DOMAIN, MANUFACTURER
-
-_LOGGER = logging.getLogger(__name__)
-
-# These are the available sensors mapped to binary_sensor class
-WELCOME_SENSOR_TYPES = {
- "Someone known": "motion",
- "Someone unknown": "motion",
- "Motion": "motion",
-}
-PRESENCE_SENSOR_TYPES = {
- "Outdoor motion": "motion",
- "Outdoor human": "motion",
- "Outdoor animal": "motion",
- "Outdoor vehicle": "motion",
-}
-TAG_SENSOR_TYPES = {"Tag Vibration": "vibration", "Tag Open": "opening"}
-
-SENSOR_TYPES = {
- "NACamera": WELCOME_SENSOR_TYPES,
- "NOC": PRESENCE_SENSOR_TYPES,
-}
-
-CONF_HOME = "home"
-CONF_CAMERAS = "cameras"
-CONF_WELCOME_SENSORS = "welcome_sensors"
-CONF_PRESENCE_SENSORS = "presence_sensors"
-CONF_TAG_SENSORS = "tag_sensors"
-
-DEFAULT_TIMEOUT = 90
-
-
-async def async_setup_entry(hass, entry, async_add_entities):
- """Set up the access to Netatmo binary sensor."""
- auth = hass.data[DOMAIN][entry.entry_id][AUTH]
-
- def get_entities():
- """Retrieve Netatmo entities."""
- entities = []
-
- def get_camera_home_id(data, camera_id):
- """Return the home id for a given camera id."""
- for home_id in data.camera_data.cameras:
- for camera in data.camera_data.cameras[home_id].values():
- if camera["id"] == camera_id:
- return home_id
- return None
-
- try:
- data = CameraData(hass, auth)
-
- for camera in data.get_all_cameras():
- home_id = get_camera_home_id(data, camera_id=camera["id"])
-
- sensor_types = {}
- sensor_types.update(SENSOR_TYPES[camera["type"]])
-
- # Tags are only supported with Netatmo Welcome indoor cameras
- modules = data.get_modules(camera["id"])
- if camera["type"] == "NACamera" and modules:
- for module in modules:
- for sensor_type in TAG_SENSOR_TYPES:
- _LOGGER.debug(
- "Adding camera tag %s (%s)",
- module["name"],
- module["id"],
- )
- entities.append(
- NetatmoBinarySensor(
- data,
- camera["id"],
- home_id,
- sensor_type,
- module["id"],
- )
- )
-
- for sensor_type in sensor_types:
- entities.append(
- NetatmoBinarySensor(data, camera["id"], home_id, sensor_type)
- )
- except pyatmo.NoDevice:
- _LOGGER.debug("No camera entities to add")
-
- return entities
-
- async_add_entities(await hass.async_add_executor_job(get_entities), True)
-
-
-class NetatmoBinarySensor(BinarySensorDevice):
- """Represent a single binary sensor in a Netatmo Camera device."""
-
- def __init__(self, data, camera_id, home_id, sensor_type, module_id=None):
- """Set up for access to the Netatmo camera events."""
- self._data = data
- self._camera_id = camera_id
- self._module_id = module_id
- self._sensor_type = sensor_type
- camera_info = data.camera_data.cameraById(cid=camera_id)
- self._camera_name = camera_info["name"]
- self._camera_type = camera_info["type"]
- self._home_id = home_id
- self._home_name = self._data.camera_data.getHomeName(home_id=home_id)
- self._timeout = DEFAULT_TIMEOUT
- if module_id:
- self._module_name = data.camera_data.moduleById(mid=module_id)["name"]
- self._name = (
- f"{MANUFACTURER} {self._camera_name} {self._module_name} {sensor_type}"
- )
- self._unique_id = (
- f"{self._camera_id}-{self._module_id}-"
- f"{self._camera_type}-{sensor_type}"
- )
- else:
- self._name = f"{MANUFACTURER} {self._camera_name} {sensor_type}"
- self._unique_id = f"{self._camera_id}-{self._camera_type}-{sensor_type}"
- self._state = None
-
- @property
- def name(self):
- """Return the name of the Netatmo device and this sensor."""
- return self._name
-
- @property
- def unique_id(self):
- """Return the unique ID for this sensor."""
- return self._unique_id
-
- @property
- def device_class(self):
- """Return the class of this sensor."""
- if self._camera_type == "NACamera":
- return WELCOME_SENSOR_TYPES.get(self._sensor_type)
- if self._camera_type == "NOC":
- return PRESENCE_SENSOR_TYPES.get(self._sensor_type)
- return TAG_SENSOR_TYPES.get(self._sensor_type)
-
- @property
- def device_info(self):
- """Return the device info for the sensor."""
- return {
- "identifiers": {(DOMAIN, self._camera_id)},
- "name": self._camera_name,
- "manufacturer": MANUFACTURER,
- "model": self._camera_type,
- }
-
- @property
- def is_on(self):
- """Return true if binary sensor is on."""
- return self._state
-
- def update(self):
- """Request an update from the Netatmo API."""
- self._data.update()
- self._data.update_event(camera_type=self._camera_type)
-
- if self._camera_type == "NACamera":
- if self._sensor_type == "Someone known":
- self._state = self._data.camera_data.someone_known_seen(
- cid=self._camera_id, exclude=self._timeout
- )
- elif self._sensor_type == "Someone unknown":
- self._state = self._data.camera_data.someone_unknown_seen(
- cid=self._camera_id, exclude=self._timeout
- )
- elif self._sensor_type == "Motion":
- self._state = self._data.camera_data.motion_detected(
- cid=self._camera_id, exclude=self._timeout
- )
- elif self._camera_type == "NOC":
- if self._sensor_type == "Outdoor motion":
- self._state = self._data.camera_data.outdoor_motion_detected(
- cid=self._camera_id, offset=self._timeout
- )
- elif self._sensor_type == "Outdoor human":
- self._state = self._data.camera_data.human_detected(
- cid=self._camera_id, offset=self._timeout
- )
- elif self._sensor_type == "Outdoor animal":
- self._state = self._data.camera_data.animal_detected(
- cid=self._camera_id, offset=self._timeout
- )
- elif self._sensor_type == "Outdoor vehicle":
- self._state = self._data.camera_data.car_detected(
- cid=self._camera_id, offset=self._timeout
- )
- if self._sensor_type == "Tag Vibration":
- self._state = self._data.camera_data.module_motion_detected(
- mid=self._module_id, cid=self._camera_id, exclude=self._timeout
- )
- elif self._sensor_type == "Tag Open":
- self._state = self._data.camera_data.module_opened(
- mid=self._module_id, cid=self._camera_id, exclude=self._timeout
- )
diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py
index 08a3847c0b7..616d2a620c5 100644
--- a/homeassistant/components/netatmo/camera.py
+++ b/homeassistant/components/netatmo/camera.py
@@ -22,6 +22,7 @@ from .const import (
MANUFACTURER,
MIN_TIME_BETWEEN_EVENT_UPDATES,
MIN_TIME_BETWEEN_UPDATES,
+ MODELS,
)
_LOGGER = logging.getLogger(__name__)
@@ -149,7 +150,7 @@ class NetatmoCamera(Camera):
"identifiers": {(DOMAIN, self._camera_id)},
"name": self._camera_name,
"manufacturer": MANUFACTURER,
- "model": self._camera_type,
+ "model": MODELS[self._camera_type],
}
@property
@@ -224,23 +225,13 @@ class NetatmoCamera(Camera):
camera = self._data.camera_data.get_camera(cid=self._camera_id)
- # URLs
self._vpnurl, self._localurl = self._data.camera_data.camera_urls(
cid=self._camera_id
)
-
- # Monitoring status
self._status = camera.get("status")
-
- # SD Card status
self._sd_status = camera.get("sd_status")
-
- # Power status
self._alim_status = camera.get("alim_status")
-
- # Is local
self._is_local = camera.get("is_local")
-
self.is_streaming = self._alim_status == "on"
diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py
index f36328a5887..aa269cfeb49 100644
--- a/homeassistant/components/netatmo/climate.py
+++ b/homeassistant/components/netatmo/climate.py
@@ -36,6 +36,7 @@ from .const import (
AUTH,
DOMAIN,
MANUFACTURER,
+ MODELS,
SERVICE_SETSCHEDULE,
)
@@ -187,7 +188,7 @@ class NetatmoThermostat(ClimateDevice):
"identifiers": {(DOMAIN, self._room_id)},
"name": self._room_name,
"manufacturer": MANUFACTURER,
- "model": self._module_type,
+ "model": MODELS[self._module_type],
}
@property
@@ -447,6 +448,9 @@ class ThermostatData:
"""Call the NetAtmo API to update the data."""
try:
self.homestatus = pyatmo.HomeStatus(self.auth, home_id=self.home_id)
+ except pyatmo.exceptions.NoDevice:
+ _LOGGER.error("No device found")
+ return
except TypeError:
_LOGGER.error("Error when getting homestatus")
return
diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py
index 5d981dc23b4..0a0c9575600 100644
--- a/homeassistant/components/netatmo/const.py
+++ b/homeassistant/components/netatmo/const.py
@@ -6,48 +6,50 @@ API = "api"
DOMAIN = "netatmo"
MANUFACTURER = "Netatmo"
+MODELS = {
+ "NAPlug": "Relay",
+ "NATherm1": "Smart Thermostat",
+ "NRV": "Smart Radiator Valves",
+ "NACamera": "Smart Indoor Camera",
+ "NOC": "Smart Outdoor Camera",
+ "NSD": "Smart Smoke Alarm",
+ "NACamDoorTag": "Smart Door and Window Sensors",
+ "NAMain": "Smart Home Weather station – indoor module",
+ "NAModule1": "Smart Home Weather station – outdoor module",
+ "NAModule4": "Smart Additional Indoor module",
+ "NAModule3": "Smart Rain Gauge",
+ "NAModule2": "Smart Anemometer",
+ "NHC": "Home Coach",
+}
+
AUTH = "netatmo_auth"
CONF_PUBLIC = "public_sensor_config"
CAMERA_DATA = "netatmo_camera"
HOME_DATA = "netatmo_home_data"
+CONF_CLOUDHOOK_URL = "cloudhook_url"
+
OAUTH2_AUTHORIZE = "https://api.netatmo.com/oauth2/authorize"
OAUTH2_TOKEN = "https://api.netatmo.com/oauth2/token"
DATA_PERSONS = "netatmo_persons"
NETATMO_WEBHOOK_URL = None
+NETATMO_EVENT = "netatmo_event"
DEFAULT_PERSON = "Unknown"
DEFAULT_DISCOVERY = True
DEFAULT_WEBHOOKS = False
-EVENT_PERSON = "person"
-EVENT_MOVEMENT = "movement"
-EVENT_HUMAN = "human"
-EVENT_ANIMAL = "animal"
-EVENT_VEHICLE = "vehicle"
-
-EVENT_BUS_PERSON = "netatmo_person"
-EVENT_BUS_MOVEMENT = "netatmo_movement"
-EVENT_BUS_HUMAN = "netatmo_human"
-EVENT_BUS_ANIMAL = "netatmo_animal"
-EVENT_BUS_VEHICLE = "netatmo_vehicle"
-EVENT_BUS_OTHER = "netatmo_other"
-
ATTR_ID = "id"
ATTR_PSEUDO = "pseudo"
ATTR_NAME = "name"
ATTR_EVENT_TYPE = "event_type"
-ATTR_MESSAGE = "message"
-ATTR_CAMERA_ID = "camera_id"
ATTR_HOME_ID = "home_id"
ATTR_HOME_NAME = "home_name"
ATTR_PERSONS = "persons"
ATTR_IS_KNOWN = "is_known"
ATTR_FACE_URL = "face_url"
-ATTR_SNAPSHOT_URL = "snapshot_url"
-ATTR_VIGNETTE_URL = "vignette_url"
ATTR_SCHEDULE_ID = "schedule_id"
ATTR_SCHEDULE_NAME = "schedule_name"
diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json
index 6fe084cc885..6e1c3d9f8f4 100644
--- a/homeassistant/components/netatmo/manifest.json
+++ b/homeassistant/components/netatmo/manifest.json
@@ -3,7 +3,10 @@
"name": "Netatmo",
"documentation": "https://www.home-assistant.io/integrations/netatmo",
"requirements": [
- "pyatmo==3.2.4"
+ "pyatmo==3.3.0"
+ ],
+ "after_dependencies": [
+ "cloud"
],
"dependencies": [
"webhook"
diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py
index 818662ee69c..fcddf58daaa 100644
--- a/homeassistant/components/netatmo/sensor.py
+++ b/homeassistant/components/netatmo/sensor.py
@@ -5,15 +5,18 @@ import logging
import pyatmo
from homeassistant.const import (
+ CONCENTRATION_PARTS_PER_MILLION,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
+ SPEED_KILOMETERS_PER_HOUR,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle
-from .const import AUTH, DOMAIN, MANUFACTURER
+from .const import AUTH, DOMAIN, MANUFACTURER, MODELS
_LOGGER = logging.getLogger(__name__)
@@ -52,24 +55,39 @@ SENSOR_TYPES = {
"mdi:thermometer",
DEVICE_CLASS_TEMPERATURE,
],
- "co2": ["CO2", "ppm", "mdi:periodic-table-co2", None],
+ "co2": ["CO2", CONCENTRATION_PARTS_PER_MILLION, "mdi:periodic-table-co2", None],
"pressure": ["Pressure", "mbar", "mdi:gauge", None],
"noise": ["Noise", "dB", "mdi:volume-high", None],
- "humidity": ["Humidity", "%", "mdi:water-percent", DEVICE_CLASS_HUMIDITY],
+ "humidity": [
+ "Humidity",
+ UNIT_PERCENTAGE,
+ "mdi:water-percent",
+ DEVICE_CLASS_HUMIDITY,
+ ],
"rain": ["Rain", "mm", "mdi:weather-rainy", None],
"sum_rain_1": ["sum_rain_1", "mm", "mdi:weather-rainy", None],
"sum_rain_24": ["sum_rain_24", "mm", "mdi:weather-rainy", None],
"battery_vp": ["Battery", "", "mdi:battery", None],
"battery_lvl": ["Battery_lvl", "", "mdi:battery", None],
- "battery_percent": ["battery_percent", "%", None, DEVICE_CLASS_BATTERY],
+ "battery_percent": ["battery_percent", UNIT_PERCENTAGE, None, DEVICE_CLASS_BATTERY],
"min_temp": ["Min Temp.", TEMP_CELSIUS, "mdi:thermometer", None],
"max_temp": ["Max Temp.", TEMP_CELSIUS, "mdi:thermometer", None],
"windangle": ["Angle", "", "mdi:compass", None],
"windangle_value": ["Angle Value", "º", "mdi:compass", None],
- "windstrength": ["Wind Strength", "km/h", "mdi:weather-windy", None],
+ "windstrength": [
+ "Wind Strength",
+ SPEED_KILOMETERS_PER_HOUR,
+ "mdi:weather-windy",
+ None,
+ ],
"gustangle": ["Gust Angle", "", "mdi:compass", None],
"gustangle_value": ["Gust Angle Value", "º", "mdi:compass", None],
- "guststrength": ["Gust Strength", "km/h", "mdi:weather-windy", None],
+ "guststrength": [
+ "Gust Strength",
+ SPEED_KILOMETERS_PER_HOUR,
+ "mdi:weather-windy",
+ None,
+ ],
"reachable": ["Reachability", "", "mdi:signal", None],
"rf_status": ["Radio", "", "mdi:signal", None],
"rf_status_lvl": ["Radio_lvl", "", "mdi:signal", None],
@@ -184,7 +202,7 @@ class NetatmoSensor(Entity):
"identifiers": {(DOMAIN, self._module_id)},
"name": self.module_name,
"manufacturer": MANUFACTURER,
- "model": self._module_type,
+ "model": MODELS[self._module_type],
}
@property
@@ -215,7 +233,9 @@ class NetatmoSensor(Entity):
data = self.netatmo_data.data.get(self._module_id)
if data is None:
- _LOGGER.info("No data found for %s (%s)", self.module_name, self._module_id)
+ _LOGGER.debug(
+ "No data found for %s (%s)", self.module_name, self._module_id
+ )
_LOGGER.debug("data: %s", self.netatmo_data.data)
self._state = None
return
diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py
new file mode 100644
index 00000000000..8b6a4d3f1e1
--- /dev/null
+++ b/homeassistant/components/netatmo/webhook.py
@@ -0,0 +1,65 @@
+"""The Netatmo integration."""
+import logging
+
+from homeassistant.core import callback
+
+from .const import (
+ ATTR_EVENT_TYPE,
+ ATTR_FACE_URL,
+ ATTR_ID,
+ ATTR_IS_KNOWN,
+ ATTR_NAME,
+ ATTR_PERSONS,
+ DATA_PERSONS,
+ DEFAULT_PERSON,
+ DOMAIN,
+ NETATMO_EVENT,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def handle_webhook(hass, webhook_id, request):
+ """Handle webhook callback."""
+ try:
+ data = await request.json()
+ except ValueError:
+ return None
+
+ _LOGGER.debug("Got webhook data: %s", data)
+
+ event_type = data.get(ATTR_EVENT_TYPE)
+
+ if event_type == "outdoor":
+ hass.bus.async_fire(
+ event_type=NETATMO_EVENT, event_data={"type": event_type, "data": data}
+ )
+ for event_data in data.get("event_list"):
+ async_evaluate_event(hass, event_data)
+ else:
+ async_evaluate_event(hass, data)
+
+
+@callback
+def async_evaluate_event(hass, event_data):
+ """Evaluate events from webhook."""
+ event_type = event_data.get(ATTR_EVENT_TYPE)
+
+ if event_type == "person":
+ for person in event_data.get(ATTR_PERSONS):
+ person_event_data = dict(event_data)
+ person_event_data[ATTR_ID] = person.get(ATTR_ID)
+ person_event_data[ATTR_NAME] = hass.data[DOMAIN][DATA_PERSONS].get(
+ person_event_data[ATTR_ID], DEFAULT_PERSON
+ )
+ person_event_data[ATTR_IS_KNOWN] = person.get(ATTR_IS_KNOWN)
+ person_event_data[ATTR_FACE_URL] = person.get(ATTR_FACE_URL)
+ hass.bus.async_fire(
+ event_type=NETATMO_EVENT,
+ event_data={"type": event_type, "data": person_event_data},
+ )
+ else:
+ hass.bus.async_fire(
+ event_type=NETATMO_EVENT,
+ event_data={"type": event_type, "data": event_data},
+ )
diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py
index edabef9535c..4406734b094 100644
--- a/homeassistant/components/netdata/sensor.py
+++ b/homeassistant/components/netdata/sensor.py
@@ -13,6 +13,7 @@ from homeassistant.const import (
CONF_NAME,
CONF_PORT,
CONF_RESOURCES,
+ UNIT_PERCENTAGE,
)
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -78,7 +79,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
try:
resource_data = netdata.api.metrics[sensor]
unit = (
- "%"
+ UNIT_PERCENTAGE
if resource_data["units"] == "percentage"
else resource_data["units"]
)
diff --git a/homeassistant/components/netgear_lte/sensor_types.py b/homeassistant/components/netgear_lte/sensor_types.py
index a744937dacd..883b4803544 100644
--- a/homeassistant/components/netgear_lte/sensor_types.py
+++ b/homeassistant/components/netgear_lte/sensor_types.py
@@ -1,7 +1,7 @@
"""Define possible sensor types."""
from homeassistant.components.binary_sensor import DEVICE_CLASS_CONNECTIVITY
-from homeassistant.const import DATA_MEBIBYTES
+from homeassistant.const import DATA_MEBIBYTES, UNIT_PERCENTAGE
SENSOR_SMS = "sms"
SENSOR_SMS_TOTAL = "sms_total"
@@ -11,7 +11,7 @@ SENSOR_UNITS = {
SENSOR_SMS: "unread",
SENSOR_SMS_TOTAL: "messages",
SENSOR_USAGE: DATA_MEBIBYTES,
- "radio_quality": "%",
+ "radio_quality": UNIT_PERCENTAGE,
"rx_level": "dBm",
"tx_level": "dBm",
"upstream": None,
diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py
index 52f0af607bc..280733affd2 100644
--- a/homeassistant/components/nfandroidtv/notify.py
+++ b/homeassistant/components/nfandroidtv/notify.py
@@ -14,7 +14,7 @@ from homeassistant.components.notify import (
PLATFORM_SCHEMA,
BaseNotificationService,
)
-from homeassistant.const import CONF_HOST, CONF_TIMEOUT
+from homeassistant.const import CONF_HOST, CONF_TIMEOUT, UNIT_PERCENTAGE
import homeassistant.helpers.config_validation as cv
_LOGGER = logging.getLogger(__name__)
@@ -66,7 +66,14 @@ POSITIONS = {
"center": 4,
}
-TRANSPARENCIES = {"default": 0, "0%": 1, "25%": 2, "50%": 3, "75%": 4, "100%": 5}
+TRANSPARENCIES = {
+ "default": 0,
+ f"0{UNIT_PERCENTAGE}": 1,
+ f"25{UNIT_PERCENTAGE}": 2,
+ f"50{UNIT_PERCENTAGE}": 3,
+ f"75{UNIT_PERCENTAGE}": 4,
+ f"100{UNIT_PERCENTAGE}": 5,
+}
COLORS = {
"grey": "#607d8b",
diff --git a/homeassistant/components/nissan_leaf/sensor.py b/homeassistant/components/nissan_leaf/sensor.py
index bbdb8ad7527..33b0efa5d60 100644
--- a/homeassistant/components/nissan_leaf/sensor.py
+++ b/homeassistant/components/nissan_leaf/sensor.py
@@ -1,7 +1,7 @@
"""Battery Charge and Range Support for the Nissan Leaf."""
import logging
-from homeassistant.const import DEVICE_CLASS_BATTERY
+from homeassistant.const import DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE
from homeassistant.helpers.icon import icon_for_battery_level
from homeassistant.util.distance import LENGTH_KILOMETERS, LENGTH_MILES
from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM
@@ -56,7 +56,7 @@ class LeafBatterySensor(LeafEntity):
@property
def unit_of_measurement(self):
"""Battery state measured in percentage."""
- return "%"
+ return UNIT_PERCENTAGE
@property
def icon(self):
diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py
index 4865b0a9839..dfa43c35952 100644
--- a/homeassistant/components/nmbs/sensor.py
+++ b/homeassistant/components/nmbs/sensor.py
@@ -11,6 +11,7 @@ from homeassistant.const import (
ATTR_LONGITUDE,
CONF_NAME,
CONF_SHOW_ON_MAP,
+ TIME_MINUTES,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -184,7 +185,7 @@ class NMBSSensor(Entity):
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
- return "min"
+ return TIME_MINUTES
@property
def icon(self):
diff --git a/homeassistant/components/notion/.translations/da.json b/homeassistant/components/notion/.translations/da.json
index bf17b41d777..784d106b94c 100644
--- a/homeassistant/components/notion/.translations/da.json
+++ b/homeassistant/components/notion/.translations/da.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "already_configured": "Dette brugernavn er allerede i brug."
+ },
"error": {
"identifier_exists": "Brugernavn er allerede registreret",
"invalid_credentials": "Ugyldigt brugernavn eller adgangskode",
diff --git a/homeassistant/components/notion/.translations/es.json b/homeassistant/components/notion/.translations/es.json
index ed17f83974c..08d02bd7493 100644
--- a/homeassistant/components/notion/.translations/es.json
+++ b/homeassistant/components/notion/.translations/es.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "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",
diff --git a/homeassistant/components/notion/.translations/it.json b/homeassistant/components/notion/.translations/it.json
index 035c0c38952..18ad0987aa7 100644
--- a/homeassistant/components/notion/.translations/it.json
+++ b/homeassistant/components/notion/.translations/it.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "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",
diff --git a/homeassistant/components/notion/.translations/lb.json b/homeassistant/components/notion/.translations/lb.json
index 1dcf1c429eb..bc9fa9633b2 100644
--- a/homeassistant/components/notion/.translations/lb.json
+++ b/homeassistant/components/notion/.translations/lb.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "already_configured": "D\u00ebse Benotzernumm g\u00ebtt scho benotzt."
+ },
"error": {
"identifier_exists": "Benotzernumm ass scho registr\u00e9iert",
"invalid_credentials": "Ong\u00ebltege Benotzernumm oder Passwuert",
diff --git a/homeassistant/components/notion/.translations/ru.json b/homeassistant/components/notion/.translations/ru.json
index 15b540732a7..6e64ebbe7aa 100644
--- a/homeassistant/components/notion/.translations/ru.json
+++ b/homeassistant/components/notion/.translations/ru.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "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.",
diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py
index 1e04c4a8e8e..f387e820253 100644
--- a/homeassistant/components/notion/__init__.py
+++ b/homeassistant/components/notion/__init__.py
@@ -22,7 +22,6 @@ from homeassistant.helpers.dispatcher import (
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_time_interval
-from .config_flow import configured_instances
from .const import DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, TOPIC_DATA_UPDATE
_LOGGER = logging.getLogger(__name__)
@@ -84,9 +83,6 @@ async def async_setup(hass, config):
conf = config[DOMAIN]
- if conf[CONF_USERNAME] in configured_instances(hass):
- return True
-
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
@@ -103,6 +99,11 @@ async def async_setup(hass, config):
async def async_setup_entry(hass, config_entry):
"""Set up Notion as a config entry."""
+ if not config_entry.unique_id:
+ hass.config_entries.async_update_entry(
+ config_entry, unique_id=config_entry.data[CONF_USERNAME]
+ )
+
session = aiohttp_client.async_get_clientsession(hass)
try:
diff --git a/homeassistant/components/notion/config_flow.py b/homeassistant/components/notion/config_flow.py
index 2af231d582e..58c5c0d44ee 100644
--- a/homeassistant/components/notion/config_flow.py
+++ b/homeassistant/components/notion/config_flow.py
@@ -5,35 +5,27 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
-from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client
-from .const import DOMAIN
+from .const import DOMAIN # pylint: disable=unused-import
-@callback
-def configured_instances(hass):
- """Return a set of configured Notion instances."""
- return set(
- entry.data[CONF_USERNAME] for entry in hass.config_entries.async_entries(DOMAIN)
- )
-
-
-@config_entries.HANDLERS.register(DOMAIN)
-class NotionFlowHandler(config_entries.ConfigFlow):
+class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a Notion config flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
- async def _show_form(self, errors=None):
- """Show the form to the user."""
- data_schema = vol.Schema(
+ def __init__(self):
+ """Initialize the config flow."""
+ self.data_schema = vol.Schema(
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
)
+ async def _show_form(self, errors=None):
+ """Show the form to the user."""
return self.async_show_form(
- step_id="user", data_schema=data_schema, errors=errors or {}
+ step_id="user", data_schema=self.data_schema, errors=errors or {}
)
async def async_step_import(self, import_config):
@@ -42,12 +34,11 @@ class NotionFlowHandler(config_entries.ConfigFlow):
async def async_step_user(self, user_input=None):
"""Handle the start of the config flow."""
-
if not user_input:
return await self._show_form()
- if user_input[CONF_USERNAME] in configured_instances(self.hass):
- return await self._show_form({CONF_USERNAME: "identifier_exists"})
+ await self.async_set_unique_id(user_input[CONF_USERNAME])
+ self._abort_if_unique_id_configured()
session = aiohttp_client.async_get_clientsession(self.hass)
diff --git a/homeassistant/components/notion/strings.json b/homeassistant/components/notion/strings.json
index 8825e25bfe8..fa47c2819ba 100644
--- a/homeassistant/components/notion/strings.json
+++ b/homeassistant/components/notion/strings.json
@@ -11,9 +11,11 @@
}
},
"error": {
- "identifier_exists": "Username already registered",
"invalid_credentials": "Invalid username or password",
"no_devices": "No devices found in account"
+ },
+ "abort": {
+ "already_configured": "This username is already in use."
}
}
}
diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py
index 7fda26b2900..943dbc02fbf 100644
--- a/homeassistant/components/nuki/lock.py
+++ b/homeassistant/components/nuki/lock.py
@@ -77,26 +77,28 @@ class NukiLock(LockDevice):
def __init__(self, nuki_lock):
"""Initialize the lock."""
self._nuki_lock = nuki_lock
- self._locked = nuki_lock.is_locked
- self._name = nuki_lock.name
- self._battery_critical = nuki_lock.battery_critical
self._available = nuki_lock.state not in ERROR_STATES
@property
def name(self):
"""Return the name of the lock."""
- return self._name
+ return self._nuki_lock.name
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return self._nuki_lock.nuki_id
@property
def is_locked(self):
"""Return true if lock is locked."""
- return self._locked
+ return self._nuki_lock.is_locked
@property
def device_state_attributes(self):
"""Return the device specific state attributes."""
data = {
- ATTR_BATTERY_CRITICAL: self._battery_critical,
+ ATTR_BATTERY_CRITICAL: self._nuki_lock.battery_critical,
ATTR_NUKI_ID: self._nuki_lock.nuki_id,
}
return data
@@ -119,17 +121,13 @@ class NukiLock(LockDevice):
except RequestException:
_LOGGER.warning("Network issues detect with %s", self.name)
self._available = False
- return
+ continue
# If in error state, we force an update and repoll data
self._available = self._nuki_lock.state not in ERROR_STATES
if self._available:
break
- self._name = self._nuki_lock.name
- self._locked = self._nuki_lock.is_locked
- self._battery_critical = self._nuki_lock.battery_critical
-
def lock(self, **kwargs):
"""Lock the device."""
self._nuki_lock.lock()
diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py
index bdf0eaafc99..15dee84dd9b 100644
--- a/homeassistant/components/nut/sensor.py
+++ b/homeassistant/components/nut/sensor.py
@@ -18,6 +18,8 @@ from homeassistant.const import (
POWER_WATT,
STATE_UNKNOWN,
TEMP_CELSIUS,
+ TIME_SECONDS,
+ UNIT_PERCENTAGE,
)
from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.config_validation as cv
@@ -50,21 +52,21 @@ SENSOR_TYPES = {
"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", "%", "mdi:gauge"],
- "ups.load.high": ["Overload Setting", "%", "mdi:gauge"],
+ "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", "s", "mdi:timer"],
- "ups.delay.reboot": ["UPS Reboot Delay", "s", "mdi:timer"],
- "ups.delay.shutdown": ["UPS Shutdown Delay", "s", "mdi:timer"],
- "ups.timer.start": ["Load Start Timer", "s", "mdi:timer"],
- "ups.timer.reboot": ["Load Reboot Timer", "s", "mdi:timer"],
- "ups.timer.shutdown": ["Load Shutdown Timer", "s", "mdi:timer"],
- "ups.test.interval": ["Self-Test Interval", "s", "mdi:timer"],
+ "ups.delay.start": ["Load Restart Delay", TIME_SECONDS, "mdi:timer"],
+ "ups.delay.reboot": ["UPS Reboot Delay", TIME_SECONDS, "mdi:timer"],
+ "ups.delay.shutdown": ["UPS Shutdown Delay", TIME_SECONDS, "mdi:timer"],
+ "ups.timer.start": ["Load Start Timer", TIME_SECONDS, "mdi:timer"],
+ "ups.timer.reboot": ["Load Reboot Timer", TIME_SECONDS, "mdi:timer"],
+ "ups.timer.shutdown": ["Load Shutdown Timer", TIME_SECONDS, "mdi:timer"],
+ "ups.test.interval": ["Self-Test Interval", TIME_SECONDS, "mdi:timer"],
"ups.test.result": ["Self-Test Result", "", "mdi:information-outline"],
"ups.test.date": ["Self-Test Date", "", "mdi:calendar"],
"ups.display.language": ["Language", "", "mdi:information-outline"],
"ups.contacts": ["External Contacts", "", "mdi:information-outline"],
- "ups.efficiency": ["Efficiency", "%", "mdi:gauge"],
+ "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"],
@@ -76,10 +78,18 @@ SENSOR_TYPES = {
"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", "%", "mdi:gauge"],
- "battery.charge.low": ["Low Battery Setpoint", "%", "mdi:gauge"],
- "battery.charge.restart": ["Minimum Battery to Start", "%", "mdi:gauge"],
- "battery.charge.warning": ["Warning Battery Setpoint", "%", "mdi:gauge"],
+ "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"],
@@ -89,9 +99,13 @@ SENSOR_TYPES = {
"battery.current": ["Battery Current", "A", "mdi:flash"],
"battery.current.total": ["Total Battery Current", "A", "mdi:flash"],
"battery.temperature": ["Battery Temperature", TEMP_CELSIUS, "mdi:thermometer"],
- "battery.runtime": ["Battery Runtime", "s", "mdi:timer"],
- "battery.runtime.low": ["Low Battery Runtime", "s", "mdi:timer"],
- "battery.runtime.restart": ["Minimum Battery Runtime to Start", "s", "mdi:timer"],
+ "battery.runtime": ["Battery Runtime", TIME_SECONDS, "mdi:timer"],
+ "battery.runtime.low": ["Low Battery Runtime", TIME_SECONDS, "mdi:timer"],
+ "battery.runtime.restart": [
+ "Minimum Battery Runtime to Start",
+ TIME_SECONDS,
+ "mdi:timer",
+ ],
"battery.alarm.threshold": [
"Battery Alarm Threshold",
"",
@@ -189,8 +203,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
data.update(no_throttle=True)
except data.pynuterror as err:
_LOGGER.error(
- "Failure while testing NUT status retrieval. Cannot continue setup: %s",
- err,
+ "Failure while testing NUT status retrieval. Cannot continue setup: %s", err
)
raise PlatformNotReady
diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py
index 24ef18ab985..89d2c1c01da 100644
--- a/homeassistant/components/nzbget/sensor.py
+++ b/homeassistant/components/nzbget/sensor.py
@@ -1,7 +1,11 @@
"""Monitor the NZBGet API."""
import logging
-from homeassistant.const import DATA_MEGABYTES, DATA_RATE_MEGABYTES_PER_SECOND
+from homeassistant.const import (
+ DATA_MEGABYTES,
+ DATA_RATE_MEGABYTES_PER_SECOND,
+ TIME_MINUTES,
+)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
@@ -26,7 +30,7 @@ SENSOR_TYPES = {
"post_job_count": ["PostJobCount", "Post Processing Jobs", "Jobs"],
"post_paused": ["PostPaused", "Post Processing Paused", None],
"remaining_size": ["RemainingSizeMB", "Queue Size", DATA_MEGABYTES],
- "uptime": ["UpTimeSec", "Uptime", "min"],
+ "uptime": ["UpTimeSec", "Uptime", TIME_MINUTES],
}
diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py
index f73e525efe3..dba7e656aff 100644
--- a/homeassistant/components/octoprint/__init__.py
+++ b/homeassistant/components/octoprint/__init__.py
@@ -19,6 +19,8 @@ from homeassistant.const import (
CONF_SSL,
CONTENT_TYPE_JSON,
TEMP_CELSIUS,
+ TIME_SECONDS,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
@@ -70,9 +72,21 @@ SENSOR_TYPES = {
# API Endpoint, Group, Key, unit, icon
"Temperatures": ["printer", "temperature", "*", TEMP_CELSIUS],
"Current State": ["printer", "state", "text", None, "mdi:printer-3d"],
- "Job Percentage": ["job", "progress", "completion", "%", "mdi:file-percent"],
- "Time Remaining": ["job", "progress", "printTimeLeft", "seconds", "mdi:clock-end"],
- "Time Elapsed": ["job", "progress", "printTime", "seconds", "mdi:clock-start"],
+ "Job Percentage": [
+ "job",
+ "progress",
+ "completion",
+ UNIT_PERCENTAGE,
+ "mdi:file-percent",
+ ],
+ "Time Remaining": [
+ "job",
+ "progress",
+ "printTimeLeft",
+ TIME_SECONDS,
+ "mdi:clock-end",
+ ],
+ "Time Elapsed": ["job", "progress", "printTime", TIME_SECONDS, "mdi:clock-start"],
}
SENSOR_SCHEMA = vol.Schema(
diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py
index 98d878fc2ea..83b247c39cb 100644
--- a/homeassistant/components/octoprint/sensor.py
+++ b/homeassistant/components/octoprint/sensor.py
@@ -3,7 +3,7 @@ import logging
import requests
-from homeassistant.const import TEMP_CELSIUS
+from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE
from homeassistant.helpers.entity import Entity
from . import DOMAIN as COMPONENT_DOMAIN, SENSOR_TYPES
@@ -111,7 +111,7 @@ class OctoPrintSensor(Entity):
def state(self):
"""Return the state of the sensor."""
sensor_unit = self.unit_of_measurement
- if sensor_unit in (TEMP_CELSIUS, "%"):
+ if sensor_unit in (TEMP_CELSIUS, UNIT_PERCENTAGE):
# API sometimes returns null and not 0
if self._state is None:
self._state = 0
diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py
index 6a7f282ac87..41f41a6e93d 100644
--- a/homeassistant/components/onewire/sensor.py
+++ b/homeassistant/components/onewire/sensor.py
@@ -8,7 +8,7 @@ from pyownet import protocol
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_HOST, CONF_PORT, TEMP_CELSIUS
+from homeassistant.const import CONF_HOST, CONF_PORT, TEMP_CELSIUS, UNIT_PERCENTAGE
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -60,14 +60,14 @@ HOBBYBOARD_EF = {
SENSOR_TYPES = {
# SensorType: [ Measured unit, Unit ]
"temperature": ["temperature", TEMP_CELSIUS],
- "humidity": ["humidity", "%"],
- "humidity_raw": ["humidity", "%"],
+ "humidity": ["humidity", UNIT_PERCENTAGE],
+ "humidity_raw": ["humidity", UNIT_PERCENTAGE],
"pressure": ["pressure", "mb"],
"illuminance": ["illuminance", "lux"],
- "wetness_0": ["wetness", "%"],
- "wetness_1": ["wetness", "%"],
- "wetness_2": ["wetness", "%"],
- "wetness_3": ["wetness", "%"],
+ "wetness_0": ["wetness", UNIT_PERCENTAGE],
+ "wetness_1": ["wetness", UNIT_PERCENTAGE],
+ "wetness_2": ["wetness", UNIT_PERCENTAGE],
+ "wetness_3": ["wetness", UNIT_PERCENTAGE],
"moisture_0": ["moisture", "cb"],
"moisture_1": ["moisture", "cb"],
"moisture_2": ["moisture", "cb"],
diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py
index 3f244530dca..ce241f779b1 100644
--- a/homeassistant/components/onvif/camera.py
+++ b/homeassistant/components/onvif/camera.py
@@ -1,13 +1,9 @@
-"""
-Support for ONVIF Cameras with FFmpeg as decoder.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/camera.onvif/
-"""
+"""Support for ONVIF Cameras with FFmpeg as decoder."""
import asyncio
import datetime as dt
import logging
import os
+from typing import Optional
from aiohttp.client_exceptions import ClientConnectionError, ServerDisconnectedError
from haffmpeg.camera import CameraMjpeg
@@ -15,10 +11,10 @@ from haffmpeg.tools import IMAGE_JPEG, ImageFrame
import onvif
from onvif import ONVIFCamera, exceptions
import voluptuous as vol
+from zeep.asyncio import AsyncTransport
from zeep.exceptions import Fault
from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera
-from homeassistant.components.camera.const import DOMAIN
from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS, DATA_FFMPEG
from homeassistant.const import (
ATTR_ENTITY_ID,
@@ -29,7 +25,10 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.exceptions import PlatformNotReady
-from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
+from homeassistant.helpers.aiohttp_client import (
+ async_aiohttp_proxy_stream,
+ async_get_clientsession,
+)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.service import async_extract_entity_ids
import homeassistant.util.dt as dt_util
@@ -44,10 +43,15 @@ DEFAULT_ARGUMENTS = "-pred 1"
DEFAULT_PROFILE = 0
CONF_PROFILE = "profile"
+CONF_RTSP_TRANSPORT = "rtsp_transport"
ATTR_PAN = "pan"
ATTR_TILT = "tilt"
ATTR_ZOOM = "zoom"
+ATTR_DISTANCE = "distance"
+ATTR_SPEED = "speed"
+ATTR_MOVE_MODE = "move_mode"
+ATTR_CONTINUOUS_DURATION = "continuous_duration"
DIR_UP = "UP"
DIR_DOWN = "DOWN"
@@ -55,13 +59,21 @@ DIR_LEFT = "LEFT"
DIR_RIGHT = "RIGHT"
ZOOM_OUT = "ZOOM_OUT"
ZOOM_IN = "ZOOM_IN"
-PTZ_NONE = "NONE"
+PAN_FACTOR = {DIR_RIGHT: 1, DIR_LEFT: -1}
+TILT_FACTOR = {DIR_UP: 1, DIR_DOWN: -1}
+ZOOM_FACTOR = {ZOOM_IN: 1, ZOOM_OUT: -1}
+CONTINUOUS_MOVE = "ContinuousMove"
+RELATIVE_MOVE = "RelativeMove"
+ABSOLUTE_MOVE = "AbsoluteMove"
-SERVICE_PTZ = "onvif_ptz"
+SERVICE_PTZ = "ptz"
+DOMAIN = "onvif"
ONVIF_DATA = "onvif"
ENTITIES = "entities"
+RTSP_TRANS_PROTOCOLS = ["tcp", "udp", "udp_multicast", "http"]
+
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
@@ -70,6 +82,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_EXTRA_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string,
+ vol.Optional(CONF_RTSP_TRANSPORT, default=RTSP_TRANS_PROTOCOLS[0]): vol.In(
+ RTSP_TRANS_PROTOCOLS
+ ),
vol.Optional(CONF_PROFILE, default=DEFAULT_PROFILE): vol.All(
vol.Coerce(int), vol.Range(min=0)
),
@@ -79,9 +94,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
SERVICE_PTZ_SCHEMA = vol.Schema(
{
ATTR_ENTITY_ID: cv.entity_ids,
- ATTR_PAN: vol.In([DIR_LEFT, DIR_RIGHT, PTZ_NONE]),
- ATTR_TILT: vol.In([DIR_UP, DIR_DOWN, PTZ_NONE]),
- ATTR_ZOOM: vol.In([ZOOM_OUT, ZOOM_IN, PTZ_NONE]),
+ vol.Optional(ATTR_PAN): vol.In([DIR_LEFT, DIR_RIGHT]),
+ vol.Optional(ATTR_TILT): vol.In([DIR_UP, DIR_DOWN]),
+ vol.Optional(ATTR_ZOOM): vol.In([ZOOM_OUT, ZOOM_IN]),
+ ATTR_MOVE_MODE: vol.In([CONTINUOUS_MOVE, RELATIVE_MOVE, ABSOLUTE_MOVE]),
+ vol.Optional(ATTR_CONTINUOUS_DURATION, default=0.5): cv.small_float,
+ vol.Optional(ATTR_DISTANCE, default=0.1): cv.small_float,
+ vol.Optional(ATTR_SPEED, default=0.5): cv.small_float,
}
)
@@ -92,9 +111,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
async def async_handle_ptz(service):
"""Handle PTZ service call."""
- pan = service.data.get(ATTR_PAN, None)
- tilt = service.data.get(ATTR_TILT, None)
- zoom = service.data.get(ATTR_ZOOM, None)
+ pan = service.data.get(ATTR_PAN)
+ tilt = service.data.get(ATTR_TILT)
+ zoom = service.data.get(ATTR_ZOOM)
+ distance = service.data[ATTR_DISTANCE]
+ speed = service.data[ATTR_SPEED]
+ move_mode = service.data.get(ATTR_MOVE_MODE)
+ continuous_duration = service.data[ATTR_CONTINUOUS_DURATION]
all_cameras = hass.data[ONVIF_DATA][ENTITIES]
entity_ids = await async_extract_entity_ids(hass, service)
target_cameras = []
@@ -105,7 +128,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
camera for camera in all_cameras if camera.entity_id in entity_ids
]
for camera in target_cameras:
- await camera.async_perform_ptz(pan, tilt, zoom)
+ await camera.async_perform_ptz(
+ pan, tilt, zoom, distance, speed, move_mode, continuous_duration
+ )
hass.services.async_register(
DOMAIN, SERVICE_PTZ, async_handle_ptz, schema=SERVICE_PTZ_SCHEMA
@@ -141,17 +166,22 @@ class ONVIFHassCamera(Camera):
self._profile_index = config.get(CONF_PROFILE)
self._ptz_service = None
self._input = None
+ self.stream_options[CONF_RTSP_TRANSPORT] = config.get(CONF_RTSP_TRANSPORT)
+ self._mac = None
_LOGGER.debug(
"Setting up the ONVIF camera device @ '%s:%s'", self._host, self._port
)
+ session = async_get_clientsession(hass)
+ transport = AsyncTransport(None, session=session)
self._camera = ONVIFCamera(
self._host,
self._port,
self._username,
self._password,
"{}/wsdl/".format(os.path.dirname(onvif.__file__)),
+ transport=transport,
)
async def async_initialize(self):
@@ -165,6 +195,7 @@ class ONVIFHassCamera(Camera):
_LOGGER.debug("Updating service addresses")
await self._camera.update_xaddrs()
+ await self.async_obtain_mac_address()
await self.async_check_date_and_time()
await self.async_obtain_input_uri()
self.setup_ptz()
@@ -183,6 +214,14 @@ class ONVIFHassCamera(Camera):
err,
)
+ async def async_obtain_mac_address(self):
+ """Obtain the MAC address of the camera to use as the unique ID."""
+ devicemgmt = self._camera.create_devicemgmt_service()
+ network_interfaces = await devicemgmt.GetNetworkInterfaces()
+ for interface in network_interfaces:
+ if interface.Enabled:
+ self._mac = interface.Info.HwAddress
+
async def async_check_date_and_time(self):
"""Warns if camera and system date not synced."""
_LOGGER.debug("Setting up the ONVIF device management service")
@@ -250,6 +289,35 @@ class ONVIFHassCamera(Camera):
"Couldn't get camera '%s' date/time. Error: %s", self._name, err
)
+ async def async_obtain_profile_token(self):
+ """Obtain profile token to use with requests."""
+ try:
+ media_service = self._camera.get_service("media")
+
+ 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)
+
+ return profiles[self._profile_index].token
+ except exceptions.ONVIFError as err:
+ _LOGGER.error(
+ "Couldn't retrieve profile token of camera '%s'. Error: %s",
+ self._name,
+ err,
+ )
+ return None
+
async def async_obtain_input_uri(self):
"""Set the input uri for the camera."""
_LOGGER.debug(
@@ -311,33 +379,63 @@ class ONVIFHassCamera(Camera):
_LOGGER.debug("PTZ is not available")
else:
self._ptz_service = self._camera.create_ptz_service()
- _LOGGER.debug("Completed set up of the ONVIF camera component")
+ _LOGGER.debug("Completed set up of the ONVIF camera component")
- async def async_perform_ptz(self, pan, tilt, zoom):
+ async def async_perform_ptz(
+ self, pan, tilt, zoom, distance, speed, move_mode, continuous_duration
+ ):
"""Perform a PTZ action on the camera."""
if self._ptz_service is None:
_LOGGER.warning("PTZ actions are not supported on camera '%s'", self._name)
return
if self._ptz_service:
- pan_val = 1 if pan == DIR_RIGHT else -1 if pan == DIR_LEFT else 0
- tilt_val = 1 if tilt == DIR_UP else -1 if tilt == DIR_DOWN else 0
- zoom_val = 1 if zoom == ZOOM_IN else -1 if zoom == ZOOM_OUT else 0
- req = {
- "Velocity": {
- "PanTilt": {"_x": pan_val, "_y": tilt_val},
- "Zoom": {"_x": zoom_val},
- }
- }
+ pan_val = distance * PAN_FACTOR.get(pan, 0)
+ tilt_val = distance * TILT_FACTOR.get(tilt, 0)
+ zoom_val = distance * ZOOM_FACTOR.get(zoom, 0)
+ speed_val = speed
+ _LOGGER.debug(
+ "Calling %s PTZ | Pan = %4.2f | Tilt = %4.2f | Zoom = %4.2f | Speed = %4.2f",
+ move_mode,
+ pan_val,
+ tilt_val,
+ zoom_val,
+ speed_val,
+ )
try:
- _LOGGER.debug(
- "Calling PTZ | Pan = %d | Tilt = %d | Zoom = %d",
- pan_val,
- tilt_val,
- zoom_val,
- )
+ req = self._ptz_service.create_type(move_mode)
+ req.ProfileToken = await self.async_obtain_profile_token()
+ if move_mode == CONTINUOUS_MOVE:
+ req.Velocity = {
+ "PanTilt": {"x": pan_val, "y": tilt_val},
+ "Zoom": {"x": zoom_val},
+ }
- await self._ptz_service.ContinuousMove(req)
+ await self._ptz_service.ContinuousMove(req)
+ await asyncio.sleep(continuous_duration)
+ req = self._ptz_service.create_type("Stop")
+ req.ProfileToken = await self.async_obtain_profile_token()
+ await self._ptz_service.Stop({"ProfileToken": req.ProfileToken})
+ elif move_mode == RELATIVE_MOVE:
+ req.Translation = {
+ "PanTilt": {"x": pan_val, "y": tilt_val},
+ "Zoom": {"x": zoom_val},
+ }
+ req.Speed = {
+ "PanTilt": {"x": speed_val, "y": speed_val},
+ "Zoom": {"x": speed_val},
+ }
+ await self._ptz_service.RelativeMove(req)
+ elif move_mode == ABSOLUTE_MOVE:
+ req.Position = {
+ "PanTilt": {"x": pan_val, "y": tilt_val},
+ "Zoom": {"x": zoom_val},
+ }
+ req.Speed = {
+ "PanTilt": {"x": speed_val, "y": speed_val},
+ "Zoom": {"x": speed_val},
+ }
+ await self._ptz_service.AbsoluteMove(req)
except exceptions.ONVIFError as err:
if "Bad Request" in err.reason:
self._ptz_service = None
@@ -403,3 +501,8 @@ class ONVIFHassCamera(Camera):
def name(self):
"""Return the name of this camera."""
return self._name
+
+ @property
+ def unique_id(self) -> Optional[str]:
+ """Return a unique ID."""
+ return self._mac
diff --git a/homeassistant/components/onvif/services.yaml b/homeassistant/components/onvif/services.yaml
index 667538f056a..8d14633cc9c 100644
--- a/homeassistant/components/onvif/services.yaml
+++ b/homeassistant/components/onvif/services.yaml
@@ -1,16 +1,31 @@
-onvif_ptz:
+ptz:
description: If your ONVIF camera supports PTZ, you will be able to pan, tilt or zoom your camera.
fields:
entity_id:
- description: 'String or list of strings that point at entity_ids of cameras. Else targets all.'
- example: 'camera.backyard'
+ description: "String or list of strings that point at entity_ids of cameras. Else targets all."
+ example: "camera.living_room_camera"
tilt:
- description: 'Tilt direction. Allowed values: UP, DOWN, NONE'
- example: 'UP'
+ description: "Tilt direction. Allowed values: UP, DOWN"
+ example: "UP"
pan:
- description: 'Pan direction. Allowed values: RIGHT, LEFT, NONE'
- example: 'RIGHT'
+ description: "Pan direction. Allowed values: RIGHT, LEFT"
+ example: "RIGHT"
zoom:
- description: 'Zoom. Allowed values: ZOOM_IN, ZOOM_OUT, NONE'
- example: 'NONE'
-
+ description: "Zoom. Allowed values: ZOOM_IN, ZOOM_OUT"
+ example: "ZOOM_IN"
+ distance:
+ description: "Distance coefficient. Sets how much PTZ should be executed in one request. Allowed values: floating point numbers, 0 to 1"
+ default: 0.1
+ example: 0.1
+ speed:
+ description: "Speed coefficient. Sets how fast PTZ will be executed. Allowed values: floating point numbers, 0 to 1"
+ default: 0.5
+ example: 0.5
+ continuous_duration:
+ description: "Set ContinuousMove delay in seconds before stopping the move"
+ default: 0.5
+ example: 0.5
+ move_mode:
+ description: "PTZ moving mode. One of ContinuousMove, RelativeMove or AbsoluteMove"
+ default: "RelativeMove"
+ example: "ContinuousMove"
diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py
index 0ac655cd448..e0f21f6946d 100644
--- a/homeassistant/components/openevse/sensor.py
+++ b/homeassistant/components/openevse/sensor.py
@@ -11,6 +11,7 @@ from homeassistant.const import (
CONF_MONITORED_VARIABLES,
ENERGY_KILO_WATT_HOUR,
TEMP_CELSIUS,
+ TIME_MINUTES,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -19,7 +20,7 @@ _LOGGER = logging.getLogger(__name__)
SENSOR_TYPES = {
"status": ["Charging Status", None],
- "charge_time": ["Charge Time Elapsed", "minutes"],
+ "charge_time": ["Charge Time Elapsed", TIME_MINUTES],
"ambient_temp": ["Ambient Temperature", TEMP_CELSIUS],
"ir_temp": ["IR Temperature", TEMP_CELSIUS],
"rtc_temp": ["RTC Temperature", TEMP_CELSIUS],
diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py
index 5d6ee47c3eb..967bce6007e 100644
--- a/homeassistant/components/openhome/media_player.py
+++ b/homeassistant/components/openhome/media_player.py
@@ -85,7 +85,7 @@ class OpenhomeDevice(MediaPlayerDevice):
self._supported_features |= (
SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET
)
- self._volume_level = self._device.VolumeLevel()
+ self._volume_level = self._device.VolumeLevel() / 100.0
self._volume_muted = self._device.IsMuted()
for source in self._device.Sources():
@@ -222,7 +222,7 @@ class OpenhomeDevice(MediaPlayerDevice):
@property
def volume_level(self):
"""Volume level of the media player (0..1)."""
- return self._volume_level / 100.0
+ return self._volume_level
@property
def is_volume_muted(self):
diff --git a/homeassistant/components/opentherm_gw/.translations/lv.json b/homeassistant/components/opentherm_gw/.translations/lv.json
new file mode 100644
index 00000000000..2c146e9d563
--- /dev/null
+++ b/homeassistant/components/opentherm_gw/.translations/lv.json
@@ -0,0 +1,12 @@
+{
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "floor_temperature": "Gr\u012bdas temperat\u016bra",
+ "precision": "Precizit\u0101te"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py
index bd9b372de33..b8d427ba193 100644
--- a/homeassistant/components/opentherm_gw/const.py
+++ b/homeassistant/components/opentherm_gw/const.py
@@ -1,7 +1,13 @@
"""Constants for the opentherm_gw integration."""
import pyotgw.vars as gw_vars
-from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS
+from homeassistant.const import (
+ DEVICE_CLASS_TEMPERATURE,
+ TEMP_CELSIUS,
+ TIME_HOURS,
+ TIME_MINUTES,
+ UNIT_PERCENTAGE,
+)
ATTR_GW_ID = "gateway_id"
ATTR_LEVEL = "level"
@@ -31,10 +37,8 @@ SERVICE_SET_OAT = "set_outside_temperature"
SERVICE_SET_SB_TEMP = "set_setback_temperature"
UNIT_BAR = "bar"
-UNIT_HOUR = "h"
UNIT_KW = "kW"
-UNIT_L_MIN = "L/min"
-UNIT_PERCENT = "%"
+UNIT_L_MIN = f"L/{TIME_MINUTES}"
BINARY_SENSOR_INFO = {
# [device_class, friendly_name format]
@@ -117,7 +121,7 @@ SENSOR_INFO = {
gw_vars.DATA_MASTER_MEMBERID: [None, None, "Thermostat Member ID {}"],
gw_vars.DATA_SLAVE_MEMBERID: [None, None, "Boiler Member ID {}"],
gw_vars.DATA_SLAVE_OEM_FAULT: [None, None, "Boiler OEM Fault Code {}"],
- gw_vars.DATA_COOLING_CONTROL: [None, UNIT_PERCENT, "Cooling Control Signal {}"],
+ gw_vars.DATA_COOLING_CONTROL: [None, UNIT_PERCENTAGE, "Cooling Control Signal {}"],
gw_vars.DATA_CONTROL_SETPOINT_2: [
DEVICE_CLASS_TEMPERATURE,
TEMP_CELSIUS,
@@ -130,13 +134,13 @@ SENSOR_INFO = {
],
gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD: [
None,
- UNIT_PERCENT,
+ UNIT_PERCENTAGE,
"Boiler Maximum Relative Modulation {}",
],
gw_vars.DATA_SLAVE_MAX_CAPACITY: [None, UNIT_KW, "Boiler Maximum Capacity {}"],
gw_vars.DATA_SLAVE_MIN_MOD_LEVEL: [
None,
- UNIT_PERCENT,
+ UNIT_PERCENTAGE,
"Boiler Minimum Modulation Level {}",
],
gw_vars.DATA_ROOM_SETPOINT: [
@@ -144,7 +148,7 @@ SENSOR_INFO = {
TEMP_CELSIUS,
"Room Setpoint {}",
],
- gw_vars.DATA_REL_MOD_LEVEL: [None, UNIT_PERCENT, "Relative Modulation Level {}"],
+ gw_vars.DATA_REL_MOD_LEVEL: [None, UNIT_PERCENTAGE, "Relative Modulation Level {}"],
gw_vars.DATA_CH_WATER_PRESS: [None, UNIT_BAR, "Central Heating Water Pressure {}"],
gw_vars.DATA_DHW_FLOW_RATE: [None, UNIT_L_MIN, "Hot Water Flow Rate {}"],
gw_vars.DATA_ROOM_SETPOINT_2: [
@@ -237,10 +241,10 @@ SENSOR_INFO = {
gw_vars.DATA_CH_PUMP_STARTS: [None, None, "Central Heating Pump Starts {}"],
gw_vars.DATA_DHW_PUMP_STARTS: [None, None, "Hot Water Pump Starts {}"],
gw_vars.DATA_DHW_BURNER_STARTS: [None, None, "Hot Water Burner Starts {}"],
- gw_vars.DATA_TOTAL_BURNER_HOURS: [None, UNIT_HOUR, "Total Burner Hours {}"],
- gw_vars.DATA_CH_PUMP_HOURS: [None, UNIT_HOUR, "Central Heating Pump Hours {}"],
- gw_vars.DATA_DHW_PUMP_HOURS: [None, UNIT_HOUR, "Hot Water Pump Hours {}"],
- gw_vars.DATA_DHW_BURNER_HOURS: [None, UNIT_HOUR, "Hot Water Burner Hours {}"],
+ gw_vars.DATA_TOTAL_BURNER_HOURS: [None, TIME_HOURS, "Total Burner Hours {}"],
+ gw_vars.DATA_CH_PUMP_HOURS: [None, TIME_HOURS, "Central Heating Pump Hours {}"],
+ gw_vars.DATA_DHW_PUMP_HOURS: [None, TIME_HOURS, "Hot Water Pump Hours {}"],
+ gw_vars.DATA_DHW_BURNER_HOURS: [None, TIME_HOURS, "Hot Water Burner Hours {}"],
gw_vars.DATA_MASTER_OT_VERSION: [None, None, "Thermostat OpenTherm Version {}"],
gw_vars.DATA_SLAVE_OT_VERSION: [None, None, "Boiler OpenTherm Version {}"],
gw_vars.DATA_MASTER_PRODUCT_TYPE: [None, None, "Thermostat Product Type {}"],
diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py
index 2df62bcc09f..a375cfa10d7 100644
--- a/homeassistant/components/openuv/sensor.py
+++ b/homeassistant/components/openuv/sensor.py
@@ -1,6 +1,7 @@
"""Support for OpenUV sensors."""
import logging
+from homeassistant.const import TIME_MINUTES
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util.dt import as_local, parse_datetime
@@ -50,32 +51,32 @@ SENSORS = {
TYPE_SAFE_EXPOSURE_TIME_1: (
"Skin Type 1 Safe Exposure Time",
"mdi:timer",
- "minutes",
+ TIME_MINUTES,
),
TYPE_SAFE_EXPOSURE_TIME_2: (
"Skin Type 2 Safe Exposure Time",
"mdi:timer",
- "minutes",
+ TIME_MINUTES,
),
TYPE_SAFE_EXPOSURE_TIME_3: (
"Skin Type 3 Safe Exposure Time",
"mdi:timer",
- "minutes",
+ TIME_MINUTES,
),
TYPE_SAFE_EXPOSURE_TIME_4: (
"Skin Type 4 Safe Exposure Time",
"mdi:timer",
- "minutes",
+ TIME_MINUTES,
),
TYPE_SAFE_EXPOSURE_TIME_5: (
"Skin Type 5 Safe Exposure Time",
"mdi:timer",
- "minutes",
+ TIME_MINUTES,
),
TYPE_SAFE_EXPOSURE_TIME_6: (
"Skin Type 6 Safe Exposure Time",
"mdi:timer",
- "minutes",
+ TIME_MINUTES,
),
}
diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py
index 23f88f59aad..ce32458f640 100644
--- a/homeassistant/components/openweathermap/sensor.py
+++ b/homeassistant/components/openweathermap/sensor.py
@@ -12,8 +12,10 @@ from homeassistant.const import (
CONF_API_KEY,
CONF_MONITORED_CONDITIONS,
CONF_NAME,
+ SPEED_METERS_PER_SECOND,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -33,11 +35,11 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120)
SENSOR_TYPES = {
"weather": ["Condition", None],
"temperature": ["Temperature", None],
- "wind_speed": ["Wind speed", "m/s"],
+ "wind_speed": ["Wind speed", SPEED_METERS_PER_SECOND],
"wind_bearing": ["Wind bearing", "°"],
- "humidity": ["Humidity", "%"],
+ "humidity": ["Humidity", UNIT_PERCENTAGE],
"pressure": ["Pressure", "mbar"],
- "clouds": ["Cloud coverage", "%"],
+ "clouds": ["Cloud coverage", UNIT_PERCENTAGE],
"rain": ["Rain", "mm"],
"snow": ["Snow", "mm"],
"weather_code": ["Weather code", None],
diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py
index 71494e9e805..cf034950154 100644
--- a/homeassistant/components/owntracks/__init__.py
+++ b/homeassistant/components/owntracks/__init__.py
@@ -16,7 +16,7 @@ from homeassistant.setup import async_when_setup
from .config_flow import CONF_SECRET
from .const import DOMAIN
-from .messages import async_handle_message
+from .messages import async_handle_message, encrypt_message
_LOGGER = logging.getLogger(__name__)
@@ -154,6 +154,7 @@ async def handle_webhook(hass, webhook_id, request):
Android does not set a topic but adds headers to the request.
"""
context = hass.data[DOMAIN]["context"]
+ topic_base = re.sub("/#$", "", context.mqtt_topic)
try:
message = await request.json()
@@ -168,7 +169,6 @@ async def handle_webhook(hass, webhook_id, request):
device = headers.get("X-Limit-D", user)
if user:
- topic_base = re.sub("/#$", "", context.mqtt_topic)
message["topic"] = f"{topic_base}/{user}/{device}"
elif message["_type"] != "encrypted":
@@ -180,7 +180,35 @@ async def handle_webhook(hass, webhook_id, request):
return json_response([])
hass.helpers.dispatcher.async_dispatcher_send(DOMAIN, hass, context, message)
- return json_response([])
+
+ response = []
+
+ for person in hass.states.async_all():
+ if person.domain != "person":
+ continue
+
+ if "latitude" in person.attributes and "longitude" in person.attributes:
+ response.append(
+ {
+ "_type": "location",
+ "lat": person.attributes["latitude"],
+ "lon": person.attributes["longitude"],
+ "tid": "".join(p[0] for p in person.name.split(" ")[:2]),
+ "tst": int(person.last_updated.timestamp()),
+ }
+ )
+
+ if message["_type"] == "encrypted" and context.secret:
+ return json_response(
+ {
+ "_type": "encrypted",
+ "data": encrypt_message(
+ context.secret, message["topic"], json.dumps(response)
+ ),
+ }
+ )
+
+ return json_response(response)
class OwnTracksContext:
diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py
index 00fa023d6c1..89312f96c68 100644
--- a/homeassistant/components/owntracks/device_tracker.py
+++ b/homeassistant/components/owntracks/device_tracker.py
@@ -4,7 +4,7 @@ import logging
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.components.device_tracker.const import (
ATTR_SOURCE_TYPE,
- ENTITY_ID_FORMAT,
+ DOMAIN,
SOURCE_TYPE_GPS,
)
from homeassistant.const import (
@@ -68,7 +68,7 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity):
"""Set up OwnTracks entity."""
self._dev_id = dev_id
self._data = data or {}
- self.entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ self.entity_id = f"{DOMAIN}.{dev_id}"
@property
def unique_id(self):
@@ -118,11 +118,6 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity):
"""Return the name of the device."""
return self._data.get("host_name")
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
@property
def source_type(self):
"""Return the source type, eg gps or router, of the device."""
diff --git a/homeassistant/components/owntracks/messages.py b/homeassistant/components/owntracks/messages.py
index 7fab391efc1..42f1f62d10a 100644
--- a/homeassistant/components/owntracks/messages.py
+++ b/homeassistant/components/owntracks/messages.py
@@ -144,6 +144,37 @@ def _decrypt_payload(secret, topic, ciphertext):
return None
+def encrypt_message(secret, topic, message):
+ """Encrypt message."""
+
+ keylen = SecretBox.KEY_SIZE
+
+ if isinstance(secret, dict):
+ key = secret.get(topic)
+ else:
+ key = secret
+
+ if key is None:
+ _LOGGER.warning(
+ "Unable to encrypt payload because no decryption key known " "for topic %s",
+ topic,
+ )
+ return None
+
+ key = key.encode("utf-8")
+ key = key[:keylen]
+ key = key.ljust(keylen, b"\0")
+
+ try:
+ message = message.encode("utf-8")
+ payload = SecretBox(key).encrypt(message, encoder=Base64Encoder)
+ _LOGGER.debug("Encrypted message: %s to %s", message, payload)
+ return payload.decode("utf-8")
+ except ValueError:
+ _LOGGER.warning("Unable to encrypt message for topic %s", topic)
+ return None
+
+
@HANDLERS.register("location")
async def async_handle_location_message(hass, context, message):
"""Handle a location message."""
diff --git a/homeassistant/components/pandora/media_player.py b/homeassistant/components/pandora/media_player.py
index 07f697a5a46..322765ac082 100644
--- a/homeassistant/components/pandora/media_player.py
+++ b/homeassistant/components/pandora/media_player.py
@@ -120,7 +120,7 @@ class PandoraMediaPlayer(MediaPlayerDevice):
_LOGGER.warning(
"The pianobar client is not configured to log in. "
"Please create a configuration file for it as described at "
- "https://home-assistant.io/integrations/pandora/"
+ "https://www.home-assistant.io/integrations/pandora/"
)
# pass through the email/password prompts to quit cleanly
self._pianobar.sendcontrol("m")
@@ -384,6 +384,6 @@ def _pianobar_exists():
_LOGGER.warning(
"The Pandora integration depends on the Pianobar client, which "
"cannot be found. Please install using instructions at "
- "https://home-assistant.io/components/media_player.pandora/"
+ "https://www.home-assistant.io/integrations/media_player.pandora/"
)
return False
diff --git a/homeassistant/components/panel_custom/__init__.py b/homeassistant/components/panel_custom/__init__.py
index cf861992bd6..82572d7396c 100644
--- a/homeassistant/components/panel_custom/__init__.py
+++ b/homeassistant/components/panel_custom/__init__.py
@@ -146,8 +146,6 @@ async def async_setup(hass, config):
if DOMAIN not in config:
return True
- success = False
-
for panel in config[DOMAIN]:
name = panel[CONF_COMPONENT_NAME]
@@ -182,8 +180,13 @@ async def async_setup(hass, config):
hass.http.register_static_path(url, panel_path)
kwargs["html_url"] = url
- await async_register_panel(hass, **kwargs)
+ try:
+ await async_register_panel(hass, **kwargs)
+ except ValueError as err:
+ _LOGGER.error(
+ "Unable to register panel %s: %s",
+ panel.get(CONF_SIDEBAR_TITLE, name),
+ err,
+ )
- success = True
-
- return success
+ return True
diff --git a/homeassistant/components/pencom/switch.py b/homeassistant/components/pencom/switch.py
index 36266feaa6e..5cd1f826629 100644
--- a/homeassistant/components/pencom/switch.py
+++ b/homeassistant/components/pencom/switch.py
@@ -1,8 +1,4 @@
-"""Pencom relay control.
-
-For more details about this component, please refer to the documentation at
-http://home-assistant.io/components/switch.pencom
-"""
+"""Pencom relay control."""
import logging
from pencompy.pencompy import Pencompy
diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py
index 5791d17f6dd..fb06d06cfb4 100644
--- a/homeassistant/components/pi_hole/__init__.py
+++ b/homeassistant/components/pi_hole/__init__.py
@@ -115,7 +115,7 @@ async def async_setup(hass, config):
return call_data
- service_disable_schema = vol.Schema( # pylint: disable=invalid-name
+ service_disable_schema = vol.Schema(
vol.All(
{
vol.Required(SERVICE_DISABLE_ATTR_DURATION): vol.All(
diff --git a/homeassistant/components/pi_hole/const.py b/homeassistant/components/pi_hole/const.py
index ca4eea32bd6..94f687d9bfa 100644
--- a/homeassistant/components/pi_hole/const.py
+++ b/homeassistant/components/pi_hole/const.py
@@ -1,6 +1,8 @@
"""Constants for the pi_hole integration."""
from datetime import timedelta
+from homeassistant.const import UNIT_PERCENTAGE
+
DOMAIN = "pi_hole"
CONF_LOCATION = "location"
@@ -26,7 +28,7 @@ SENSOR_DICT = {
"ads_blocked_today": ["Ads Blocked Today", "ads", "mdi:close-octagon-outline"],
"ads_percentage_today": [
"Ads Percentage Blocked Today",
- "%",
+ UNIT_PERCENTAGE,
"mdi:close-octagon-outline",
],
"clients_ever_seen": ["Seen Clients", "clients", "mdi:account-outline"],
diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py
index e7c8033f2ac..34a2a1a42b6 100644
--- a/homeassistant/components/plaato/sensor.py
+++ b/homeassistant/components/plaato/sensor.py
@@ -2,6 +2,7 @@
import logging
+from homeassistant.const import UNIT_PERCENTAGE
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
@@ -145,7 +146,7 @@ class PlaatoSensor(Entity):
if self._type == ATTR_BPM:
return "bpm"
if self._type == ATTR_ABV:
- return "%"
+ return UNIT_PERCENTAGE
return ""
diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py
index 408c7d1bf36..30542db5e23 100644
--- a/homeassistant/components/plant/__init__.py
+++ b/homeassistant/components/plant/__init__.py
@@ -16,6 +16,7 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
@@ -131,14 +132,17 @@ class Plant(Entity):
"""
READINGS = {
- READING_BATTERY: {ATTR_UNIT_OF_MEASUREMENT: "%", "min": CONF_MIN_BATTERY_LEVEL},
+ READING_BATTERY: {
+ ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE,
+ "min": CONF_MIN_BATTERY_LEVEL,
+ },
READING_TEMPERATURE: {
ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS,
"min": CONF_MIN_TEMPERATURE,
"max": CONF_MAX_TEMPERATURE,
},
READING_MOISTURE: {
- ATTR_UNIT_OF_MEASUREMENT: "%",
+ ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE,
"min": CONF_MIN_MOISTURE,
"max": CONF_MAX_MOISTURE,
},
diff --git a/homeassistant/components/plant/manifest.json b/homeassistant/components/plant/manifest.json
index de5f0c1f880..f0ff20f3759 100644
--- a/homeassistant/components/plant/manifest.json
+++ b/homeassistant/components/plant/manifest.json
@@ -3,7 +3,7 @@
"name": "Plant Monitor",
"documentation": "https://www.home-assistant.io/integrations/plant",
"requirements": [],
- "dependencies": ["group", "zone"],
+ "dependencies": [],
"after_dependencies": ["recorder"],
"codeowners": ["@ChristianKuehnel"],
"quality_scale": "internal"
diff --git a/homeassistant/components/plex/.translations/lv.json b/homeassistant/components/plex/.translations/lv.json
new file mode 100644
index 00000000000..23cda3fce4b
--- /dev/null
+++ b/homeassistant/components/plex/.translations/lv.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_in_progress": "Plex tiek konfigur\u0113ts"
+ },
+ "error": {
+ "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"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py
index c9b120f75f6..9d74ed8cb75 100644
--- a/homeassistant/components/plex/__init__.py
+++ b/homeassistant/components/plex/__init__.py
@@ -48,12 +48,15 @@ from .const import (
)
from .server import PlexServer
-MEDIA_PLAYER_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_USE_EPISODE_ART, default=False): cv.boolean,
- vol.Optional(CONF_SHOW_ALL_CONTROLS, default=False): cv.boolean,
- vol.Optional(CONF_IGNORE_NEW_SHARED_USERS, default=False): cv.boolean,
- }
+MEDIA_PLAYER_SCHEMA = vol.All(
+ cv.deprecated(CONF_SHOW_ALL_CONTROLS, invalidation_version="0.110"),
+ vol.Schema(
+ {
+ vol.Optional(CONF_USE_EPISODE_ART, default=False): cv.boolean,
+ vol.Optional(CONF_SHOW_ALL_CONTROLS): cv.boolean,
+ vol.Optional(CONF_IGNORE_NEW_SHARED_USERS, default=False): cv.boolean,
+ }
+ ),
)
SERVER_CONFIG_SCHEMA = vol.Schema(
@@ -113,6 +116,11 @@ async def async_setup_entry(hass, entry):
"""Set up Plex from a config entry."""
server_config = entry.data[PLEX_SERVER_CONFIG]
+ if entry.unique_id is None:
+ hass.config_entries.async_update_entry(
+ entry, unique_id=entry.data[CONF_SERVER_IDENTIFIER]
+ )
+
if MP_DOMAIN not in entry.options:
options = dict(entry.options)
options.setdefault(
diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py
index 19cec6dfb8b..6edbecf055d 100644
--- a/homeassistant/components/plex/config_flow.py
+++ b/homeassistant/components/plex/config_flow.py
@@ -11,11 +11,10 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
-from homeassistant.const import CONF_SSL, CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL
+from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
-from homeassistant.util.json import load_json
from .const import ( # pylint: disable=unused-import
AUTH_CALLBACK_NAME,
@@ -25,11 +24,9 @@ from .const import ( # pylint: disable=unused-import
CONF_MONITORED_USERS,
CONF_SERVER,
CONF_SERVER_IDENTIFIER,
- CONF_SHOW_ALL_CONTROLS,
CONF_USE_EPISODE_ART,
DEFAULT_VERIFY_SSL,
DOMAIN,
- PLEX_CONFIG_FILE,
PLEX_SERVER_CONFIG,
SERVERS,
X_PLEX_DEVICE_NAME,
@@ -126,12 +123,8 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
server_id = plex_server.machine_identifier
- for entry in self._async_current_entries():
- if entry.data[CONF_SERVER_IDENTIFIER] == server_id:
- _LOGGER.debug(
- "Plex server already configured: %s", entry.data[CONF_SERVER]
- )
- return self.async_abort(reason="already_configured")
+ await self.async_set_unique_id(server_id)
+ self._abort_if_unique_id_configured()
url = plex_server.url_in_use
token = server_config.get(CONF_TOKEN)
@@ -185,29 +178,6 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
errors={},
)
- async def async_step_discovery(self, discovery_info):
- """Set default host and port from discovery."""
- if self._async_current_entries() or self._async_in_progress():
- # Skip discovery if a config already exists or is in progress.
- return self.async_abort(reason="already_configured")
-
- json_file = self.hass.config.path(PLEX_CONFIG_FILE)
- file_config = await self.hass.async_add_executor_job(load_json, json_file)
-
- if file_config:
- host_and_port, host_config = file_config.popitem()
- prefix = "https" if host_config[CONF_SSL] else "http"
-
- server_config = {
- CONF_URL: f"{prefix}://{host_and_port}",
- CONF_TOKEN: host_config[CONF_TOKEN],
- CONF_VERIFY_SSL: host_config["verify"],
- }
- _LOGGER.info("Imported legacy config, file can be removed: %s", json_file)
- return await self.async_step_server_validate(server_config)
-
- return self.async_abort(reason="discovery_no_file")
-
async def async_step_import(self, import_config):
"""Import from Plex configuration."""
_LOGGER.debug("Imported Plex configuration")
@@ -257,7 +227,7 @@ class PlexOptionsFlowHandler(config_entries.OptionsFlow):
def __init__(self, config_entry):
"""Initialize Plex options flow."""
- self.options = copy.deepcopy(config_entry.options)
+ self.options = copy.deepcopy(dict(config_entry.options))
self.server_id = config_entry.data[CONF_SERVER_IDENTIFIER]
async def async_step_init(self, user_input=None):
@@ -272,9 +242,6 @@ class PlexOptionsFlowHandler(config_entries.OptionsFlow):
self.options[MP_DOMAIN][CONF_USE_EPISODE_ART] = user_input[
CONF_USE_EPISODE_ART
]
- self.options[MP_DOMAIN][CONF_SHOW_ALL_CONTROLS] = user_input[
- CONF_SHOW_ALL_CONTROLS
- ]
self.options[MP_DOMAIN][CONF_IGNORE_NEW_SHARED_USERS] = user_input[
CONF_IGNORE_NEW_SHARED_USERS
]
@@ -315,10 +282,6 @@ class PlexOptionsFlowHandler(config_entries.OptionsFlow):
CONF_USE_EPISODE_ART,
default=plex_server.option_use_episode_art,
): bool,
- vol.Required(
- CONF_SHOW_ALL_CONTROLS,
- default=plex_server.option_show_all_controls,
- ): bool,
vol.Optional(
CONF_MONITORED_USERS, default=default_accounts
): cv.multi_select(available_accounts),
diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py
index 7d6812674ca..d5cb3db3aba 100644
--- a/homeassistant/components/plex/const.py
+++ b/homeassistant/components/plex/const.py
@@ -15,7 +15,6 @@ PLATFORMS_COMPLETED = "platforms_completed"
SERVERS = "servers"
WEBSOCKETS = "websockets"
-PLEX_CONFIG_FILE = "plex.conf"
PLEX_MEDIA_PLAYER_OPTIONS = "plex_mp_options"
PLEX_SERVER_CONFIG = "server_config"
diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py
index 47e5ba6104f..1be06876baf 100644
--- a/homeassistant/components/plex/media_player.py
+++ b/homeassistant/components/plex/media_player.py
@@ -17,7 +17,6 @@ from homeassistant.components.media_player.const import (
SUPPORT_PLAY_MEDIA,
SUPPORT_PREVIOUS_TRACK,
SUPPORT_STOP,
- SUPPORT_TURN_OFF,
SUPPORT_VOLUME_MUTE,
SUPPORT_VOLUME_SET,
)
@@ -334,6 +333,7 @@ class PlexMediaPlayer(MediaPlayerDevice):
def force_idle(self):
"""Force client to idle."""
+ self._player_state = STATE_IDLE
self._state = STATE_IDLE
self.session = None
self._clear_media_details()
@@ -481,46 +481,6 @@ class PlexMediaPlayer(MediaPlayerDevice):
@property
def supported_features(self):
"""Flag media player features that are supported."""
- # force show all controls
- if self.plex_server.option_show_all_controls:
- return (
- SUPPORT_PAUSE
- | SUPPORT_PREVIOUS_TRACK
- | SUPPORT_NEXT_TRACK
- | SUPPORT_STOP
- | SUPPORT_VOLUME_SET
- | SUPPORT_PLAY
- | SUPPORT_PLAY_MEDIA
- | SUPPORT_TURN_OFF
- | SUPPORT_VOLUME_MUTE
- )
-
- # no mute support
- if self.make.lower() == "shield android tv":
- _LOGGER.debug(
- "Shield Android TV client detected, disabling mute controls: %s",
- self.name,
- )
- return (
- SUPPORT_PAUSE
- | SUPPORT_PREVIOUS_TRACK
- | SUPPORT_NEXT_TRACK
- | SUPPORT_STOP
- | SUPPORT_VOLUME_SET
- | SUPPORT_PLAY
- | SUPPORT_PLAY_MEDIA
- | SUPPORT_TURN_OFF
- )
-
- # Only supports play,pause,stop (and off which really is stop)
- if self.make.lower().startswith("tivo"):
- _LOGGER.debug(
- "Tivo client detected, only enabling pause, play, "
- "stop, and off controls: %s",
- self.name,
- )
- return SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_STOP | SUPPORT_TURN_OFF
-
if self.device and "playback" in self._device_protocol_capabilities:
return (
SUPPORT_PAUSE
@@ -530,7 +490,6 @@ class PlexMediaPlayer(MediaPlayerDevice):
| SUPPORT_VOLUME_SET
| SUPPORT_PLAY
| SUPPORT_PLAY_MEDIA
- | SUPPORT_TURN_OFF
| SUPPORT_VOLUME_MUTE
)
@@ -594,11 +553,6 @@ class PlexMediaPlayer(MediaPlayerDevice):
self.device.stop(self._active_media_plexapi_type)
self.plex_server.update_platforms()
- def turn_off(self):
- """Turn the client off."""
- # Fake it since we can't turn the client off
- self.media_stop()
-
def media_next_track(self):
"""Send next track command."""
if self.device and "playback" in self._device_protocol_capabilities:
diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py
index 5532362b87a..54a248309b6 100644
--- a/homeassistant/components/plex/server.py
+++ b/homeassistant/components/plex/server.py
@@ -16,7 +16,6 @@ from .const import (
CONF_IGNORE_NEW_SHARED_USERS,
CONF_MONITORED_USERS,
CONF_SERVER,
- CONF_SHOW_ALL_CONTROLS,
CONF_USE_EPISODE_ART,
DEFAULT_VERIFY_SSL,
PLEX_NEW_MP_SIGNAL,
@@ -264,11 +263,6 @@ class PlexServer:
"""Return use_episode_art option."""
return self.options[MP_DOMAIN][CONF_USE_EPISODE_ART]
- @property
- def option_show_all_controls(self):
- """Return show_all_controls option."""
- return self.options[MP_DOMAIN][CONF_SHOW_ALL_CONTROLS]
-
@property
def option_monitored_users(self):
"""Return dict of monitored users option."""
diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json
index 1f99e28df8b..43dc47bec10 100644
--- a/homeassistant/components/plex/strings.json
+++ b/homeassistant/components/plex/strings.json
@@ -23,7 +23,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",
@@ -36,7 +35,6 @@
"description": "Options for Plex Media Players",
"data": {
"use_episode_art": "Use episode art",
- "show_all_controls": "Show all controls",
"ignore_new_shared_users": "Ignore new managed/shared users",
"monitored_users": "Monitored users"
}
diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py
index d8ad22bb470..324565aae50 100644
--- a/homeassistant/components/point/sensor.py
+++ b/homeassistant/components/point/sensor.py
@@ -7,6 +7,7 @@ from homeassistant.const import (
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util.dt import parse_datetime
@@ -21,7 +22,7 @@ DEVICE_CLASS_SOUND = "sound_level"
SENSOR_TYPES = {
DEVICE_CLASS_TEMPERATURE: (None, 1, TEMP_CELSIUS),
DEVICE_CLASS_PRESSURE: (None, 0, "hPa"),
- DEVICE_CLASS_HUMIDITY: (None, 1, "%"),
+ DEVICE_CLASS_HUMIDITY: (None, 1, UNIT_PERCENTAGE),
DEVICE_CLASS_SOUND: ("mdi:ear-hearing", 1, "dBa"),
}
diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py
index d77cb4f56da..314695458b0 100644
--- a/homeassistant/components/prometheus/__init__.py
+++ b/homeassistant/components/prometheus/__init__.py
@@ -17,6 +17,7 @@ from homeassistant.const import (
EVENT_STATE_CHANGED,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers import entityfilter, state as state_helper
import homeassistant.helpers.config_validation as cv
@@ -349,7 +350,7 @@ class PrometheusMetrics:
units = {
TEMP_CELSIUS: "c",
TEMP_FAHRENHEIT: "c", # F should go into C metric
- "%": "percent",
+ UNIT_PERCENTAGE: "percent",
}
default = unit.replace("/", "_per_")
default = default.lower()
diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py
index 28d201d78cd..3aa65734d34 100644
--- a/homeassistant/components/ps4/media_player.py
+++ b/homeassistant/components/ps4/media_player.py
@@ -5,7 +5,7 @@ import logging
from pyps4_2ndscreen.errors import NotReady, PSDataIncomplete
import pyps4_2ndscreen.ps4 as pyps4
-from homeassistant.components.media_player import ENTITY_IMAGE_URL, MediaPlayerDevice
+from homeassistant.components.media_player import MediaPlayerDevice
from homeassistant.components.media_player.const import (
ATTR_MEDIA_CONTENT_TYPE,
ATTR_MEDIA_TITLE,
@@ -387,8 +387,9 @@ class PS4Device(MediaPlayerDevice):
if self._state == STATE_PLAYING and self._media_content_id is not None:
image_hash = self.media_image_hash
if image_hash is not None:
- return ENTITY_IMAGE_URL.format(
- self.entity_id, self.access_token, image_hash
+ return (
+ f"/api/media_player_proxy/{self.entity_id}?"
+ f"token={self.access_token}&cache={image_hash}"
)
return MEDIA_IMAGE_DEFAULT
diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py
index 28cb08cc69c..a64517d2f48 100644
--- a/homeassistant/components/pushbullet/notify.py
+++ b/homeassistant/components/pushbullet/notify.py
@@ -95,10 +95,18 @@ class PushBulletNotificationService(BaseNotificationService):
# Target is email, send directly, don't use a target object.
# This also seems to work to send to all devices in own account.
if ttype == "email":
- self._push_data(message, title, data, self.pushbullet, tname)
+ self._push_data(message, title, data, self.pushbullet, email=tname)
_LOGGER.info("Sent notification to email %s", tname)
continue
+ # Target is sms, send directly, don't use a target object.
+ if ttype == "sms":
+ self._push_data(
+ message, title, data, self.pushbullet, phonenumber=tname
+ )
+ _LOGGER.info("Sent sms notification to %s", tname)
+ continue
+
# Refresh if name not found. While awaiting periodic refresh
# solution in component, poor mans refresh.
if ttype not in self.pbtargets:
@@ -120,7 +128,7 @@ class PushBulletNotificationService(BaseNotificationService):
_LOGGER.error("No such target: %s/%s", ttype, tname)
continue
- def _push_data(self, message, title, data, pusher, email=None):
+ def _push_data(self, message, title, data, pusher, email=None, phonenumber=None):
"""Create the message content."""
if data is None:
@@ -133,7 +141,10 @@ class PushBulletNotificationService(BaseNotificationService):
email_kwargs = {}
if email:
email_kwargs["email"] = email
- if url:
+ if phonenumber:
+ device = pusher.devices[0]
+ pusher.push_sms(device, phonenumber, message)
+ elif url:
pusher.push_link(title, url, body=message, **email_kwargs)
elif filepath:
if not self.hass.config.is_allowed_path(filepath):
diff --git a/homeassistant/components/qnap/manifest.json b/homeassistant/components/qnap/manifest.json
index 6720120b5e2..3c64986c2bc 100644
--- a/homeassistant/components/qnap/manifest.json
+++ b/homeassistant/components/qnap/manifest.json
@@ -2,7 +2,7 @@
"domain": "qnap",
"name": "QNAP",
"documentation": "https://www.home-assistant.io/integrations/qnap",
- "requirements": ["qnapstats==0.2.7"],
+ "requirements": ["qnapstats==0.3.0"],
"dependencies": [],
"codeowners": ["@colinodell"]
}
diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py
index 1ad53f4db48..475e02aba86 100644
--- a/homeassistant/components/qnap/sensor.py
+++ b/homeassistant/components/qnap/sensor.py
@@ -19,6 +19,7 @@ from homeassistant.const import (
DATA_GIBIBYTES,
DATA_RATE_MEBIBYTES_PER_SECOND,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.config_validation as cv
@@ -61,12 +62,12 @@ _SYSTEM_MON_COND = {
}
_CPU_MON_COND = {
"cpu_temp": ["CPU Temperature", TEMP_CELSIUS, "mdi:thermometer"],
- "cpu_usage": ["CPU Usage", "%", "mdi:chip"],
+ "cpu_usage": ["CPU Usage", UNIT_PERCENTAGE, "mdi:chip"],
}
_MEMORY_MON_COND = {
"memory_free": ["Memory Available", DATA_GIBIBYTES, "mdi:memory"],
"memory_used": ["Memory Used", DATA_GIBIBYTES, "mdi:memory"],
- "memory_percent_used": ["Memory Usage", "%", "mdi:memory"],
+ "memory_percent_used": ["Memory Usage", UNIT_PERCENTAGE, "mdi:memory"],
}
_NETWORK_MON_COND = {
"network_link_status": ["Network Link", None, "mdi:checkbox-marked-circle-outline"],
@@ -80,7 +81,7 @@ _DRIVE_MON_COND = {
_VOLUME_MON_COND = {
"volume_size_used": ["Used Space", DATA_GIBIBYTES, "mdi:chart-pie"],
"volume_size_free": ["Free Space", DATA_GIBIBYTES, "mdi:chart-pie"],
- "volume_percentage_used": ["Volume Used", "%", "mdi:chart-pie"],
+ "volume_percentage_used": ["Volume Used", UNIT_PERCENTAGE, "mdi:chart-pie"],
}
_MONITORED_CONDITIONS = (
diff --git a/homeassistant/components/qvr_pro/__init__.py b/homeassistant/components/qvr_pro/__init__.py
new file mode 100644
index 00000000000..f2840d49299
--- /dev/null
+++ b/homeassistant/components/qvr_pro/__init__.py
@@ -0,0 +1,100 @@
+"""Support for QVR Pro NVR software by QNAP."""
+
+import logging
+
+from pyqvrpro import Client
+from pyqvrpro.client import AuthenticationError, InsufficientPermissionsError
+import voluptuous as vol
+
+from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+
+from .const import (
+ CONF_EXCLUDE_CHANNELS,
+ DOMAIN,
+ SERVICE_START_RECORD,
+ SERVICE_STOP_RECORD,
+)
+
+SERVICE_CHANNEL_GUID = "guid"
+
+_LOGGER = logging.getLogger(__name__)
+
+CONFIG_SCHEMA = vol.Schema(
+ {
+ DOMAIN: vol.Schema(
+ {
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_EXCLUDE_CHANNELS, default=[]): vol.All(
+ cv.ensure_list_csv, [cv.positive_int]
+ ),
+ }
+ )
+ },
+ extra=vol.ALLOW_EXTRA,
+)
+
+SERVICE_CHANNEL_RECORD_SCHEMA = vol.Schema(
+ {vol.Required(SERVICE_CHANNEL_GUID): cv.string}
+)
+
+
+def setup(hass, config):
+ """Set up the QVR Pro component."""
+ conf = config[DOMAIN]
+ user = conf[CONF_USERNAME]
+ password = conf[CONF_PASSWORD]
+ host = conf[CONF_HOST]
+ excluded_channels = conf[CONF_EXCLUDE_CHANNELS]
+
+ try:
+ qvrpro = Client(user, password, host)
+
+ channel_resp = qvrpro.get_channel_list()
+
+ except InsufficientPermissionsError:
+ _LOGGER.error("User must have Surveillance Management permission")
+ return False
+ except AuthenticationError:
+ _LOGGER.error("Authentication failed")
+ return False
+
+ channels = []
+
+ for channel in channel_resp["channels"]:
+ if channel["channel_index"] + 1 in excluded_channels:
+ continue
+
+ channels.append(channel)
+
+ hass.data[DOMAIN] = {"channels": channels, "client": qvrpro}
+
+ load_platform(hass, CAMERA_DOMAIN, DOMAIN, {}, config)
+
+ # Register services
+ def handle_start_record(call):
+ guid = call.data[SERVICE_CHANNEL_GUID]
+ qvrpro.start_recording(guid)
+
+ def handle_stop_record(call):
+ guid = call.data[SERVICE_CHANNEL_GUID]
+ qvrpro.stop_recording(guid)
+
+ hass.services.register(
+ DOMAIN,
+ SERVICE_START_RECORD,
+ handle_start_record,
+ schema=SERVICE_CHANNEL_RECORD_SCHEMA,
+ )
+ hass.services.register(
+ DOMAIN,
+ SERVICE_STOP_RECORD,
+ handle_stop_record,
+ schema=SERVICE_CHANNEL_RECORD_SCHEMA,
+ )
+
+ return True
diff --git a/homeassistant/components/qvr_pro/camera.py b/homeassistant/components/qvr_pro/camera.py
new file mode 100644
index 00000000000..28f607165a7
--- /dev/null
+++ b/homeassistant/components/qvr_pro/camera.py
@@ -0,0 +1,102 @@
+"""Support for QVR Pro streams."""
+
+import logging
+
+from pyqvrpro.client import QVRResponseError
+
+from homeassistant.components.camera import Camera
+
+from .const import DOMAIN, SHORT_NAME
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the QVR Pro camera platform."""
+ if discovery_info is None:
+ return
+
+ client = hass.data[DOMAIN]["client"]
+
+ entities = []
+
+ for channel in hass.data[DOMAIN]["channels"]:
+
+ stream_source = get_stream_source(channel["guid"], client)
+ entities.append(
+ QVRProCamera(**channel, stream_source=stream_source, client=client)
+ )
+
+ add_entities(entities)
+
+
+def get_stream_source(guid, client):
+ """Get channel stream source."""
+ try:
+ resp = client.get_channel_live_stream(guid, protocol="rtsp")
+
+ full_url = resp["resourceUris"]
+
+ protocol = full_url[:7]
+ auth = f"{client.get_auth_string()}@"
+ url = full_url[7:]
+
+ return f"{protocol}{auth}{url}"
+
+ except QVRResponseError as ex:
+ _LOGGER.error(ex)
+ return None
+
+
+class QVRProCamera(Camera):
+ """Representation of a QVR Pro camera."""
+
+ def __init__(self, name, model, brand, channel_index, guid, stream_source, client):
+ """Init QVR Pro camera."""
+
+ self._name = f"{SHORT_NAME} {name}"
+ self._model = model
+ self._brand = brand
+ self.index = channel_index
+ self.guid = guid
+ self._client = client
+ self._stream_source = stream_source
+
+ self._supported_features = 0
+
+ super().__init__()
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def model(self):
+ """Return the model of the entity."""
+ return self._model
+
+ @property
+ def brand(self):
+ """Return the brand of the entity."""
+ return self._brand
+
+ @property
+ def device_state_attributes(self):
+ """Get the state attributes."""
+ attrs = {"qvr_guid": self.guid}
+
+ return attrs
+
+ def camera_image(self):
+ """Get image bytes from camera."""
+ return self._client.get_snapshot(self.guid)
+
+ async def stream_source(self):
+ """Get stream source."""
+ return self._stream_source
+
+ @property
+ def supported_features(self):
+ """Get supported features."""
+ return self._supported_features
diff --git a/homeassistant/components/qvr_pro/const.py b/homeassistant/components/qvr_pro/const.py
new file mode 100644
index 00000000000..eadf756a1c2
--- /dev/null
+++ b/homeassistant/components/qvr_pro/const.py
@@ -0,0 +1,9 @@
+"""Constants for QVR Pro component."""
+
+DOMAIN = "qvr_pro"
+SHORT_NAME = "QVR"
+
+CONF_EXCLUDE_CHANNELS = "exclude_channels"
+
+SERVICE_STOP_RECORD = "stop_record"
+SERVICE_START_RECORD = "start_record"
diff --git a/homeassistant/components/qvr_pro/manifest.json b/homeassistant/components/qvr_pro/manifest.json
new file mode 100644
index 00000000000..3bef827a019
--- /dev/null
+++ b/homeassistant/components/qvr_pro/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "qvr_pro",
+ "name": "QVR Pro",
+ "documentation": "https://www.home-assistant.io/integrations/qvr_pro",
+ "requirements": ["pyqvrpro==0.51"],
+ "dependencies": [],
+ "codeowners": ["@oblogic7"]
+}
diff --git a/homeassistant/components/qvr_pro/services.yaml b/homeassistant/components/qvr_pro/services.yaml
new file mode 100644
index 00000000000..cc6866fee63
--- /dev/null
+++ b/homeassistant/components/qvr_pro/services.yaml
@@ -0,0 +1,13 @@
+start_record:
+ description: Start QVR Pro recording on specified channel.
+ fields:
+ guid:
+ description: GUID of the channel to start recording.
+ example: '245EBE933C0A597EBE865C0A245E0002'
+
+stop_record:
+ description: Stop QVR Pro recording on specified channel.
+ fields:
+ guid:
+ description: GUID of the channel to stop recording.
+ example: '245EBE933C0A597EBE865C0A245E0002'
\ No newline at end of file
diff --git a/homeassistant/components/raincloud/__init__.py b/homeassistant/components/raincloud/__init__.py
index dd851c0b3e3..4f9ae7fb733 100644
--- a/homeassistant/components/raincloud/__init__.py
+++ b/homeassistant/components/raincloud/__init__.py
@@ -11,6 +11,9 @@ from homeassistant.const import (
CONF_PASSWORD,
CONF_SCAN_INTERVAL,
CONF_USERNAME,
+ TIME_DAYS,
+ TIME_MINUTES,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
@@ -56,13 +59,13 @@ ICON_MAP = {
UNIT_OF_MEASUREMENT_MAP = {
"auto_watering": "",
- "battery": "%",
+ "battery": UNIT_PERCENTAGE,
"is_watering": "",
"manual_watering": "",
"next_cycle": "",
- "rain_delay": "days",
+ "rain_delay": TIME_DAYS,
"status": "",
- "watering_time": "min",
+ "watering_time": TIME_MINUTES,
}
BINARY_SENSORS = ["is_watering", "status"]
diff --git a/homeassistant/components/rainforest_eagle/manifest.json b/homeassistant/components/rainforest_eagle/manifest.json
index cb8e95df42f..0649dfded99 100644
--- a/homeassistant/components/rainforest_eagle/manifest.json
+++ b/homeassistant/components/rainforest_eagle/manifest.json
@@ -3,7 +3,7 @@
"name": "Rainforest Eagle-200",
"documentation": "https://www.home-assistant.io/integrations/rainforest_eagle",
"requirements": [
- "eagle200_reader==0.2.1",
+ "eagle200_reader==0.2.4",
"uEagle==0.0.1"
],
"dependencies": [],
diff --git a/homeassistant/components/rainmachine/.translations/es.json b/homeassistant/components/rainmachine/.translations/es.json
index 2cb49dc0ac1..518ff39f8bf 100644
--- a/homeassistant/components/rainmachine/.translations/es.json
+++ b/homeassistant/components/rainmachine/.translations/es.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "already_configured": "Este controlador RainMachine ya est\u00e1 configurado."
+ },
"error": {
"identifier_exists": "Cuenta ya registrada",
"invalid_credentials": "Credenciales no v\u00e1lidas"
diff --git a/homeassistant/components/rainmachine/.translations/it.json b/homeassistant/components/rainmachine/.translations/it.json
index 40b49a926c7..e0bdd7a2e1d 100644
--- a/homeassistant/components/rainmachine/.translations/it.json
+++ b/homeassistant/components/rainmachine/.translations/it.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "already_configured": "Questo controller RainMachine \u00e8 gi\u00e0 configurato."
+ },
"error": {
"identifier_exists": "Account gi\u00e0 registrato",
"invalid_credentials": "Credenziali non valide"
diff --git a/homeassistant/components/rainmachine/.translations/lb.json b/homeassistant/components/rainmachine/.translations/lb.json
index 4456b105fbc..be25e92080a 100644
--- a/homeassistant/components/rainmachine/.translations/lb.json
+++ b/homeassistant/components/rainmachine/.translations/lb.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "already_configured": "D\u00ebse RainMachine Kontroller ass scho konfigur\u00e9iert."
+ },
"error": {
"identifier_exists": "Konto ass scho registr\u00e9iert",
"invalid_credentials": "Ong\u00eblteg Login Informatioune"
diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py
index 53f33f68eb9..4844a9e68c8 100644
--- a/homeassistant/components/rainmachine/__init__.py
+++ b/homeassistant/components/rainmachine/__init__.py
@@ -24,7 +24,6 @@ from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.service import verify_domain_control
-from .config_flow import configured_instances
from .const import (
DATA_CLIENT,
DATA_PROGRAMS,
@@ -34,8 +33,6 @@ from .const import (
DATA_ZONES,
DATA_ZONES_DETAILS,
DEFAULT_PORT,
- DEFAULT_SCAN_INTERVAL,
- DEFAULT_SSL,
DOMAIN,
PROGRAM_UPDATE_TOPIC,
SENSOR_UPDATE_TOPIC,
@@ -54,6 +51,8 @@ CONF_ZONE_RUN_TIME = "zone_run_time"
DEFAULT_ATTRIBUTION = "Data provided by Green Electronics LLC"
DEFAULT_ICON = "mdi:water"
+DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)
+DEFAULT_SSL = True
DEFAULT_ZONE_RUN = 60 * 10
SERVICE_ALTER_PROGRAM = vol.Schema({vol.Required(CONF_PROGRAM_ID): cv.positive_int})
@@ -85,8 +84,10 @@ CONTROLLER_SCHEMA = vol.Schema(
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
- vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period,
- vol.Optional(CONF_ZONE_RUN_TIME): cv.positive_int,
+ vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): vol.All(
+ cv.time_period, lambda value: value.total_seconds()
+ ),
+ vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN): cv.positive_int,
}
)
@@ -116,9 +117,6 @@ async def async_setup(hass, config):
conf = config[DOMAIN]
for controller in conf[CONF_CONTROLLERS]:
- if controller[CONF_IP_ADDRESS] in configured_instances(hass):
- continue
-
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=controller
@@ -130,6 +128,11 @@ async def async_setup(hass, config):
async def async_setup_entry(hass, config_entry):
"""Set up RainMachine as config entry."""
+ if not config_entry.unique_id:
+ hass.config_entries.async_update_entry(
+ config_entry, unique_id=config_entry.data[CONF_IP_ADDRESS]
+ )
+
_verify_domain_control = verify_domain_control(hass, DOMAIN)
websession = aiohttp_client.async_get_clientsession(hass)
@@ -153,7 +156,7 @@ async def async_setup_entry(hass, config_entry):
rainmachine = RainMachine(
hass,
controller,
- config_entry.data.get(CONF_ZONE_RUN_TIME, DEFAULT_ZONE_RUN),
+ config_entry.data[CONF_ZONE_RUN_TIME],
config_entry.data[CONF_SCAN_INTERVAL],
)
diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py
index 4753335da78..ffa46cc2c15 100644
--- a/homeassistant/components/rainmachine/config_flow.py
+++ b/homeassistant/components/rainmachine/config_flow.py
@@ -1,36 +1,16 @@
"""Config flow to configure the RainMachine component."""
-
-from collections import OrderedDict
-
from regenmaschine import login
from regenmaschine.errors import RainMachineError
import voluptuous as vol
from homeassistant import config_entries
-from homeassistant.const import (
- CONF_IP_ADDRESS,
- CONF_PASSWORD,
- CONF_PORT,
- CONF_SCAN_INTERVAL,
- CONF_SSL,
-)
-from homeassistant.core import callback
+from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT
from homeassistant.helpers import aiohttp_client
-from .const import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN
+from .const import DEFAULT_PORT, DOMAIN # pylint: disable=unused-import
-@callback
-def configured_instances(hass):
- """Return a set of configured RainMachine instances."""
- return set(
- entry.data[CONF_IP_ADDRESS]
- for entry in hass.config_entries.async_entries(DOMAIN)
- )
-
-
-@config_entries.HANDLERS.register(DOMAIN)
-class RainMachineFlowHandler(config_entries.ConfigFlow):
+class RainMachineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a RainMachine config flow."""
VERSION = 1
@@ -38,16 +18,19 @@ class RainMachineFlowHandler(config_entries.ConfigFlow):
def __init__(self):
"""Initialize the config flow."""
- self.data_schema = OrderedDict()
- self.data_schema[vol.Required(CONF_IP_ADDRESS)] = str
- self.data_schema[vol.Required(CONF_PASSWORD)] = str
- self.data_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int
+ self.data_schema = vol.Schema(
+ {
+ vol.Required(CONF_IP_ADDRESS): str,
+ vol.Required(CONF_PASSWORD): str,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
+ }
+ )
async def _show_form(self, errors=None):
"""Show the form to the user."""
return self.async_show_form(
step_id="user",
- data_schema=vol.Schema(self.data_schema),
+ data_schema=self.data_schema,
errors=errors if errors else {},
)
@@ -57,12 +40,11 @@ class RainMachineFlowHandler(config_entries.ConfigFlow):
async def async_step_user(self, user_input=None):
"""Handle the start of the config flow."""
-
if not user_input:
return await self._show_form()
- if user_input[CONF_IP_ADDRESS] in configured_instances(self.hass):
- return await self._show_form({CONF_IP_ADDRESS: "identifier_exists"})
+ await self.async_set_unique_id(user_input[CONF_IP_ADDRESS])
+ self._abort_if_unique_id_configured()
websession = aiohttp_client.async_get_clientsession(self.hass)
@@ -77,15 +59,6 @@ class RainMachineFlowHandler(config_entries.ConfigFlow):
except RainMachineError:
return await self._show_form({CONF_PASSWORD: "invalid_credentials"})
- # Since the config entry doesn't allow for configuration of SSL, make
- # sure it's set:
- if user_input.get(CONF_SSL) is None:
- user_input[CONF_SSL] = DEFAULT_SSL
-
- # Timedeltas are easily serializable, so store the seconds instead:
- scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
- user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds
-
# Unfortunately, RainMachine doesn't provide a way to refresh the
# access token without using the IP address and password, so we have to
# store it:
diff --git a/homeassistant/components/rainmachine/const.py b/homeassistant/components/rainmachine/const.py
index b912f8d95ef..855ff5d5df5 100644
--- a/homeassistant/components/rainmachine/const.py
+++ b/homeassistant/components/rainmachine/const.py
@@ -1,9 +1,4 @@
"""Define constants for the SimpliSafe component."""
-from datetime import timedelta
-import logging
-
-LOGGER = logging.getLogger(__package__)
-
DOMAIN = "rainmachine"
DATA_CLIENT = "client"
@@ -15,8 +10,6 @@ DATA_ZONES = "zones"
DATA_ZONES_DETAILS = "zones_details"
DEFAULT_PORT = 8080
-DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)
-DEFAULT_SSL = True
PROGRAM_UPDATE_TOPIC = f"{DOMAIN}_program_update"
SENSOR_UPDATE_TOPIC = f"{DOMAIN}_data_update"
diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json
index 6e26192ec82..7195cce2e31 100644
--- a/homeassistant/components/rainmachine/strings.json
+++ b/homeassistant/components/rainmachine/strings.json
@@ -14,6 +14,9 @@
"error": {
"identifier_exists": "Account already registered",
"invalid_credentials": "Invalid credentials"
+ },
+ "abort": {
+ "already_configured": "This RainMachine controller is already configured."
}
}
}
diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py
index af34d4dd9f6..a662a457add 100644
--- a/homeassistant/components/recorder/__init__.py
+++ b/homeassistant/components/recorder/__init__.py
@@ -68,6 +68,7 @@ CONF_DB_RETRY_WAIT = "db_retry_wait"
CONF_PURGE_KEEP_DAYS = "purge_keep_days"
CONF_PURGE_INTERVAL = "purge_interval"
CONF_EVENT_TYPES = "event_types"
+CONF_COMMIT_INTERVAL = "commit_interval"
FILTER_SCHEMA = vol.Schema(
{
@@ -98,6 +99,9 @@ CONFIG_SCHEMA = vol.Schema(
vol.Coerce(int), vol.Range(min=0)
),
vol.Optional(CONF_DB_URL): cv.string,
+ vol.Optional(CONF_COMMIT_INTERVAL, default=1): vol.All(
+ vol.Coerce(int), vol.Range(min=0)
+ ),
vol.Optional(
CONF_DB_MAX_RETRIES, default=DEFAULT_DB_MAX_RETRIES
): cv.positive_int,
@@ -141,6 +145,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
conf = config[DOMAIN]
keep_days = conf.get(CONF_PURGE_KEEP_DAYS)
purge_interval = conf.get(CONF_PURGE_INTERVAL)
+ commit_interval = conf[CONF_COMMIT_INTERVAL]
db_max_retries = conf[CONF_DB_MAX_RETRIES]
db_retry_wait = conf[CONF_DB_RETRY_WAIT]
@@ -154,6 +159,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass=hass,
keep_days=keep_days,
purge_interval=purge_interval,
+ commit_interval=commit_interval,
uri=db_url,
db_max_retries=db_max_retries,
db_retry_wait=db_retry_wait,
@@ -185,6 +191,7 @@ class Recorder(threading.Thread):
hass: HomeAssistant,
keep_days: int,
purge_interval: int,
+ commit_interval: int,
uri: str,
db_max_retries: int,
db_retry_wait: int,
@@ -197,6 +204,7 @@ class Recorder(threading.Thread):
self.hass = hass
self.keep_days = keep_days
self.purge_interval = purge_interval
+ self.commit_interval = commit_interval
self.queue: Any = queue.Queue()
self.recording_start = dt_util.utcnow()
self.db_url = uri
@@ -214,6 +222,8 @@ class Recorder(threading.Thread):
)
self.exclude_t = exclude.get(CONF_EVENT_TYPES, [])
+ self._timechanges_seen = 0
+ self.event_session = None
self.get_session = None
@callback
@@ -326,6 +336,10 @@ class Recorder(threading.Thread):
self.hass.helpers.event.track_point_in_time(async_purge, run)
+ self.event_session = self.get_session()
+ # Use a session for the event read loop
+ # with a commit every time the event time
+ # has changed. This reduces the disk io.
while True:
event = self.queue.get()
@@ -340,6 +354,11 @@ class Recorder(threading.Thread):
continue
if event.event_type == EVENT_TIME_CHANGED:
self.queue.task_done()
+ if self.commit_interval:
+ self._timechanges_seen += 1
+ if self.commit_interval >= self._timechanges_seen:
+ self._timechanges_seen = 0
+ self._commit_event_session_or_retry()
continue
if event.event_type in self.exclude_t:
self.queue.task_done()
@@ -351,55 +370,72 @@ class Recorder(threading.Thread):
self.queue.task_done()
continue
- tries = 1
- updated = False
- while not updated and tries <= self.db_max_retries:
- if tries != 1:
- time.sleep(self.db_retry_wait)
+ try:
+ dbevent = Events.from_event(event)
+ self.event_session.add(dbevent)
+ self.event_session.flush()
+ except (TypeError, ValueError):
+ _LOGGER.warning("Event is not JSON serializable: %s", event)
+
+ if dbevent and event.event_type == EVENT_STATE_CHANGED:
try:
- with session_scope(session=self.get_session()) as session:
- try:
- dbevent = Events.from_event(event)
- session.add(dbevent)
- session.flush()
- except (TypeError, ValueError):
- _LOGGER.warning("Event is not JSON serializable: %s", event)
-
- if event.event_type == EVENT_STATE_CHANGED:
- try:
- dbstate = States.from_event(event)
- dbstate.event_id = dbevent.event_id
- session.add(dbstate)
- except (TypeError, ValueError):
- _LOGGER.warning(
- "State is not JSON serializable: %s",
- event.data.get("new_state"),
- )
-
- updated = True
-
- except exc.OperationalError as err:
- _LOGGER.error(
- "Error in database connectivity: %s. "
- "(retrying in %s seconds)",
- err,
- self.db_retry_wait,
+ dbstate = States.from_event(event)
+ dbstate.event_id = dbevent.event_id
+ self.event_session.add(dbstate)
+ except (TypeError, ValueError):
+ _LOGGER.warning(
+ "State is not JSON serializable: %s",
+ event.data.get("new_state"),
)
- tries += 1
- except exc.SQLAlchemyError:
- updated = True
- _LOGGER.exception("Error saving event: %s", event)
-
- if not updated:
- _LOGGER.error(
- "Error in database update. Could not save "
- "after %d tries. Giving up",
- tries,
- )
+ # If they do not have a commit interval
+ # than we commit right away
+ if not self.commit_interval:
+ self._commit_event_session_or_retry()
self.queue.task_done()
+ def _commit_event_session_or_retry(self):
+ tries = 1
+ while tries <= self.db_max_retries:
+ if tries != 1:
+ time.sleep(self.db_retry_wait)
+
+ try:
+ self._commit_event_session()
+ return
+
+ except exc.OperationalError as err:
+ _LOGGER.error(
+ "Error in database connectivity: %s. " "(retrying in %s seconds)",
+ err,
+ self.db_retry_wait,
+ )
+ tries += 1
+
+ except exc.SQLAlchemyError:
+ _LOGGER.exception("Error saving events")
+ return
+
+ _LOGGER.error(
+ "Error in database update. Could not save " "after %d tries. Giving up",
+ tries,
+ )
+ try:
+ self.event_session.close()
+ except exc.SQLAlchemyError:
+ _LOGGER.exception("Failed to close event session.")
+
+ self.event_session = self.get_session()
+
+ def _commit_event_session(self):
+ try:
+ self.event_session.commit()
+ except Exception as err:
+ _LOGGER.error("Error executing query: %s", err)
+ self.event_session.rollback()
+ raise
+
@callback
def event_listener(self, event):
"""Listen for new events and put them in the process queue."""
@@ -465,7 +501,10 @@ class Recorder(threading.Thread):
def _close_run(self):
"""Save end time for current run."""
- with session_scope(session=self.get_session()) as session:
+ if self.event_session is not None:
self.run_info.end = dt_util.utcnow()
- session.add(self.run_info)
+ self.event_session.add(self.run_info)
+ self._commit_event_session_or_retry()
+ self.event_session.close()
+
self.run_info = None
diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py
index b7d36010714..8fdd1f2f858 100644
--- a/homeassistant/components/rejseplanen/sensor.py
+++ b/homeassistant/components/rejseplanen/sensor.py
@@ -3,9 +3,6 @@ Support for Rejseplanen information from rejseplanen.dk.
For more info on the API see:
https://help.rejseplanen.dk/hc/en-us/articles/214174465-Rejseplanen-s-API
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/sensor.rejseplanen/
"""
from datetime import datetime, timedelta
import logging
@@ -15,7 +12,7 @@ import rjpl
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
import homeassistant.util.dt as dt_util
@@ -133,7 +130,7 @@ class RejseplanenTransportSensor(Entity):
@property
def unit_of_measurement(self):
"""Return the unit this state is expressed in."""
- return "min"
+ return TIME_MINUTES
@property
def icon(self):
diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py
index 3f8bded6a85..3a71ebb94d1 100644
--- a/homeassistant/components/remote/__init__.py
+++ b/homeassistant/components/remote/__init__.py
@@ -2,10 +2,11 @@
from datetime import timedelta
import functools as ft
import logging
-from typing import Any, Iterable
+from typing import Any, Iterable, cast
import voluptuous as vol
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
SERVICE_TOGGLE,
SERVICE_TURN_OFF,
@@ -66,7 +67,9 @@ def is_on(hass: HomeAssistantType, entity_id: str) -> bool:
async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
"""Track states and offer events for remotes."""
- component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
+ component = hass.data[DOMAIN] = EntityComponent(
+ _LOGGER, DOMAIN, hass, SCAN_INTERVAL
+ )
await component.async_setup(config)
component.async_register_entity_service(
@@ -109,6 +112,18 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
return True
+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)
+ )
+
+
+async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await cast(EntityComponent, hass.data[DOMAIN]).async_unload_entry(entry)
+
+
class RemoteDevice(ToggleEntity):
"""Representation of a remote."""
diff --git a/homeassistant/components/remote/manifest.json b/homeassistant/components/remote/manifest.json
index 24616bc5947..8f559b758d6 100644
--- a/homeassistant/components/remote/manifest.json
+++ b/homeassistant/components/remote/manifest.json
@@ -3,6 +3,6 @@
"name": "Remote",
"documentation": "https://www.home-assistant.io/integrations/remote",
"requirements": [],
- "dependencies": ["group"],
+ "dependencies": [],
"codeowners": []
}
diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py
index 1d6026a8754..a5a9224464d 100644
--- a/homeassistant/components/repetier/__init__.py
+++ b/homeassistant/components/repetier/__init__.py
@@ -13,6 +13,7 @@ from homeassistant.const import (
CONF_PORT,
CONF_SENSORS,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import load_platform
@@ -123,7 +124,7 @@ SENSOR_TYPES = {
"_chamber_",
],
"current_state": ["state", None, "mdi:printer-3d", ""],
- "current_job": ["progress", "%", "mdi:file-percent", "_current_job"],
+ "current_job": ["progress", UNIT_PERCENTAGE, "mdi:file-percent", "_current_job"],
"job_end": ["progress", None, "mdi:clock-end", "_job_end"],
"job_start": ["progress", None, "mdi:clock-start", "_job_start"],
}
diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py
index 7c8cfb9d3d0..0ed62abd001 100644
--- a/homeassistant/components/rest/sensor.py
+++ b/homeassistant/components/rest/sensor.py
@@ -5,6 +5,7 @@ from xml.parsers.expat import ExpatError
from jsonpath import jsonpath
import requests
+from requests import Session
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
import voluptuous as vol
import xmltodict
@@ -206,7 +207,10 @@ class RestSensor(Entity):
# If the http request failed, headers will be None
content_type = self.rest.headers.get("content-type")
- if content_type and content_type.startswith("text/xml"):
+ if content_type and (
+ content_type.startswith("text/xml")
+ or content_type.startswith("application/xml")
+ ):
try:
value = json.dumps(xmltodict.parse(value))
_LOGGER.debug("JSON converted from XML: %s", value)
@@ -268,9 +272,14 @@ class RestData:
self._request_data = data
self._verify_ssl = verify_ssl
self._timeout = timeout
+ self._http_session = Session()
self.data = None
self.headers = None
+ def __del__(self):
+ """Destroy the http session on destroy."""
+ self._http_session.close()
+
def set_url(self, url):
"""Set url."""
self._resource = url
@@ -279,7 +288,7 @@ class RestData:
"""Get the latest data from REST service with provided method."""
_LOGGER.debug("Updating from %s", self._resource)
try:
- response = requests.request(
+ response = self._http_session.request(
self._method,
self._resource,
headers=self._headers,
diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py
index 1ed19569585..01004a3b45a 100644
--- a/homeassistant/components/rflink/light.py
+++ b/homeassistant/components/rflink/light.py
@@ -159,14 +159,12 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
hass.data[DATA_DEVICE_REGISTER][EVENT_KEY_COMMAND] = add_new_device
-# pylint: disable=too-many-ancestors
class RflinkLight(SwitchableRflinkDevice, Light):
"""Representation of a Rflink light."""
pass
-# pylint: disable=too-many-ancestors
class DimmableRflinkLight(SwitchableRflinkDevice, Light):
"""Rflink light device that support dimming."""
@@ -212,7 +210,6 @@ class DimmableRflinkLight(SwitchableRflinkDevice, Light):
return SUPPORT_BRIGHTNESS
-# pylint: disable=too-many-ancestors
class HybridRflinkLight(SwitchableRflinkDevice, Light):
"""Rflink light device that sends out both dim and on/off commands.
@@ -276,7 +273,6 @@ class HybridRflinkLight(SwitchableRflinkDevice, Light):
return SUPPORT_BRIGHTNESS
-# pylint: disable=too-many-ancestors
class ToggleRflinkLight(SwitchableRflinkDevice, Light):
"""Rflink light device which sends out only 'on' commands.
diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json
index 77b6413f994..0386e0c5bf8 100644
--- a/homeassistant/components/rflink/manifest.json
+++ b/homeassistant/components/rflink/manifest.json
@@ -2,7 +2,7 @@
"domain": "rflink",
"name": "RFLink",
"documentation": "https://www.home-assistant.io/integrations/rflink",
- "requirements": ["rflink==0.0.51"],
+ "requirements": ["rflink==0.0.52"],
"dependencies": [],
"codeowners": []
}
diff --git a/homeassistant/components/rflink/switch.py b/homeassistant/components/rflink/switch.py
index 990d76101cc..943f8a6aae6 100644
--- a/homeassistant/components/rflink/switch.py
+++ b/homeassistant/components/rflink/switch.py
@@ -69,7 +69,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
async_add_entities(devices_from_config(config))
-# pylint: disable=too-many-ancestors
class RflinkSwitch(SwitchableRflinkDevice, SwitchDevice):
"""Representation of a Rflink switch."""
diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py
index ceba82cf544..39cbde08c01 100644
--- a/homeassistant/components/rfxtrx/__init__.py
+++ b/homeassistant/components/rfxtrx/__init__.py
@@ -18,6 +18,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
POWER_WATT,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -47,7 +48,7 @@ DATA_TYPES = OrderedDict(
[
("Temperature", TEMP_CELSIUS),
("Temperature2", TEMP_CELSIUS),
- ("Humidity", "%"),
+ ("Humidity", UNIT_PERCENTAGE),
("Barometer", ""),
("Wind direction", ""),
("Rain rate", ""),
diff --git a/homeassistant/components/ring/.translations/lv.json b/homeassistant/components/ring/.translations/lv.json
new file mode 100644
index 00000000000..2c205bdd324
--- /dev/null
+++ b/homeassistant/components/ring/.translations/lv.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "Parole",
+ "username": "Lietot\u0101jv\u0101rds"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py
index 0d54db5993f..7f097d48a5f 100644
--- a/homeassistant/components/ring/__init__.py
+++ b/homeassistant/components/ring/__init__.py
@@ -205,6 +205,11 @@ class GlobalDataUpdater:
"Time out fetching Ring %s data", self.data_type,
)
return
+ except requests.RequestException as err:
+ _LOGGER.warning(
+ "Error fetching Ring %s data: %s", self.data_type, err,
+ )
+ return
for update_callback in self.listeners:
update_callback()
@@ -290,6 +295,14 @@ class DeviceDataUpdater:
device_id,
)
continue
+ except requests.RequestException as err:
+ _LOGGER.warning(
+ "Error fetching Ring %s data for device %s: %s",
+ self.data_type,
+ device_id,
+ err,
+ )
+ continue
for update_callback in info["update_callbacks"]:
self.hass.loop.call_soon_threadsafe(update_callback, data)
diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py
index 329077a18e7..84edaf67c22 100644
--- a/homeassistant/components/ring/sensor.py
+++ b/homeassistant/components/ring/sensor.py
@@ -1,6 +1,7 @@
"""This component provides HA sensor support for Ring Door Bell/Chimes."""
import logging
+from homeassistant.const import UNIT_PERCENTAGE
from homeassistant.core import callback
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.icon import icon_for_battery_level
@@ -203,7 +204,7 @@ SENSOR_TYPES = {
"battery": [
"Battery",
["doorbots", "authorized_doorbots", "stickup_cams"],
- "%",
+ UNIT_PERCENTAGE,
None,
None,
"battery",
diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py
index 507e3c133cc..704bde67a5c 100644
--- a/homeassistant/components/rmvtransport/sensor.py
+++ b/homeassistant/components/rmvtransport/sensor.py
@@ -8,7 +8,7 @@ from RMVtransport.rmvtransport import RMVtransportApiConnectionError
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
@@ -177,7 +177,7 @@ class RMVDepartureSensor(Entity):
@property
def unit_of_measurement(self):
"""Return the unit this state is expressed in."""
- return "min"
+ return TIME_MINUTES
async def async_update(self):
"""Get the latest data and update the state."""
diff --git a/homeassistant/components/roku/const.py b/homeassistant/components/roku/const.py
new file mode 100644
index 00000000000..54c52de2622
--- /dev/null
+++ b/homeassistant/components/roku/const.py
@@ -0,0 +1,2 @@
+"""Constants for the Roku integration."""
+DEFAULT_PORT = 8060
diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json
index ba67f61b2ee..20461c789e2 100644
--- a/homeassistant/components/roku/manifest.json
+++ b/homeassistant/components/roku/manifest.json
@@ -5,5 +5,5 @@
"requirements": ["roku==4.0.0"],
"dependencies": [],
"after_dependencies": ["discovery"],
- "codeowners": []
+ "codeowners": ["@ctalkington"]
}
diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py
index f3ae60ecbea..21a2f562293 100644
--- a/homeassistant/components/roku/media_player.py
+++ b/homeassistant/components/roku/media_player.py
@@ -9,7 +9,6 @@ from homeassistant.components.media_player.const import (
MEDIA_TYPE_MOVIE,
SUPPORT_NEXT_TRACK,
SUPPORT_PLAY,
- SUPPORT_PLAY_MEDIA,
SUPPORT_PREVIOUS_TRACK,
SUPPORT_SELECT_SOURCE,
SUPPORT_TURN_OFF,
@@ -25,14 +24,13 @@ from homeassistant.const import (
STATE_STANDBY,
)
-DEFAULT_PORT = 8060
+from .const import DEFAULT_PORT
_LOGGER = logging.getLogger(__name__)
SUPPORT_ROKU = (
SUPPORT_PREVIOUS_TRACK
| SUPPORT_NEXT_TRACK
- | SUPPORT_PLAY_MEDIA
| SUPPORT_VOLUME_SET
| SUPPORT_VOLUME_MUTE
| SUPPORT_SELECT_SOURCE
@@ -61,6 +59,7 @@ class RokuDevice(MediaPlayerDevice):
self.ip_address = host
self.channels = []
self.current_app = None
+ self._available = False
self._device_info = {}
self._power_state = "Unknown"
@@ -76,7 +75,10 @@ class RokuDevice(MediaPlayerDevice):
self.current_app = self.roku.current_app
else:
self.current_app = None
+
+ self._available = True
except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout):
+ self._available = False
pass
def get_source_list(self):
@@ -118,6 +120,11 @@ class RokuDevice(MediaPlayerDevice):
"""Flag media player features that are supported."""
return SUPPORT_ROKU
+ @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, Home Assistant friendly identifier for this entity."""
diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json
index 0b674588dc6..bf048cadc8f 100644
--- a/homeassistant/components/roomba/manifest.json
+++ b/homeassistant/components/roomba/manifest.json
@@ -2,7 +2,11 @@
"domain": "roomba",
"name": "iRobot Roomba",
"documentation": "https://www.home-assistant.io/integrations/roomba",
- "requirements": ["roombapy==1.4.2"],
+ "requirements": [
+ "roombapy==1.4.3"
+ ],
"dependencies": [],
- "codeowners": ["@pschmitt"]
+ "codeowners": [
+ "@pschmitt"
+ ]
}
diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py
index 797780d562a..55c2371aabb 100644
--- a/homeassistant/components/saj/sensor.py
+++ b/homeassistant/components/saj/sensor.py
@@ -22,6 +22,7 @@ from homeassistant.const import (
POWER_WATT,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
+ TIME_HOURS,
)
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.exceptions import PlatformNotReady
@@ -34,13 +35,11 @@ _LOGGER = logging.getLogger(__name__)
MIN_INTERVAL = 5
MAX_INTERVAL = 300
-UNIT_OF_MEASUREMENT_HOURS = "h"
-
INVERTER_TYPES = ["ethernet", "wifi"]
SAJ_UNIT_MAPPINGS = {
"": None,
- "h": UNIT_OF_MEASUREMENT_HOURS,
+ "h": TIME_HOURS,
"kg": MASS_KILOGRAMS,
"kWh": ENERGY_KILO_WATT_HOUR,
"W": POWER_WATT,
diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py
index bc49dc3156d..8c17ff4794c 100644
--- a/homeassistant/components/samsungtv/__init__.py
+++ b/homeassistant/components/samsungtv/__init__.py
@@ -23,6 +23,7 @@ CONFIG_SCHEMA = vol.Schema(
DOMAIN: vol.All(
cv.ensure_list,
[
+ cv.deprecated(CONF_PORT),
vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
@@ -30,7 +31,7 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_PORT): cv.port,
vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
}
- )
+ ),
],
ensure_unique_hosts,
)
diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py
new file mode 100644
index 00000000000..b582f6269e4
--- /dev/null
+++ b/homeassistant/components/samsungtv/bridge.py
@@ -0,0 +1,257 @@
+"""samsungctl and samsungtvws bridge classes."""
+from abc import ABC, abstractmethod
+
+from samsungctl import Remote
+from samsungctl.exceptions import AccessDenied, ConnectionClosed, UnhandledResponse
+from samsungtvws import SamsungTVWS
+from samsungtvws.exceptions import ConnectionFailure
+from websocket import WebSocketException
+
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_ID,
+ CONF_METHOD,
+ CONF_NAME,
+ CONF_PORT,
+ CONF_TIMEOUT,
+ CONF_TOKEN,
+)
+
+from .const import (
+ CONF_DESCRIPTION,
+ LOGGER,
+ METHOD_LEGACY,
+ RESULT_AUTH_MISSING,
+ RESULT_NOT_SUCCESSFUL,
+ RESULT_NOT_SUPPORTED,
+ RESULT_SUCCESS,
+ VALUE_CONF_ID,
+ VALUE_CONF_NAME,
+)
+
+
+class SamsungTVBridge(ABC):
+ """The Base Bridge abstract class."""
+
+ @staticmethod
+ def get_bridge(method, host, port=None, token=None):
+ """Get Bridge instance."""
+ if method == METHOD_LEGACY:
+ return SamsungTVLegacyBridge(method, host, port)
+ return SamsungTVWSBridge(method, host, port, token)
+
+ def __init__(self, method, host, port):
+ """Initialize Bridge."""
+ self.port = port
+ self.method = method
+ self.host = host
+ self.token = None
+ self.default_port = None
+ self._remote = None
+ self._callback = None
+
+ def register_reauth_callback(self, func):
+ """Register a callback function."""
+ self._callback = func
+
+ @abstractmethod
+ def try_connect(self):
+ """Try to connect to the TV."""
+
+ def is_on(self):
+ """Tells if the TV is on."""
+ self.close_remote()
+
+ try:
+ return self._get_remote() is not None
+ except (
+ UnhandledResponse,
+ AccessDenied,
+ ConnectionFailure,
+ ):
+ # We got a response so it's working.
+ return True
+ except OSError:
+ # Different reasons, e.g. hostname not resolveable
+ return False
+
+ def send_key(self, key):
+ """Send a key to the tv and handles exceptions."""
+ try:
+ # recreate connection if connection was dead
+ retry_count = 1
+ for _ in range(retry_count + 1):
+ try:
+ self._send_key(key)
+ break
+ except (
+ ConnectionClosed,
+ BrokenPipeError,
+ WebSocketException,
+ ):
+ # BrokenPipe can occur when the commands is sent to fast
+ # WebSocketException can occur when timed out
+ self._remote = None
+ except (UnhandledResponse, AccessDenied):
+ # We got a response so it's on.
+ LOGGER.debug("Failed sending command %s", key, exc_info=True)
+ except OSError:
+ # Different reasons, e.g. hostname not resolveable
+ pass
+
+ @abstractmethod
+ def _send_key(self, key):
+ """Send the key."""
+
+ @abstractmethod
+ def _get_remote(self):
+ """Get Remote object."""
+
+ def close_remote(self):
+ """Close remote object."""
+ try:
+ if self._remote is not None:
+ # Close the current remote connection
+ self._remote.close()
+ self._remote = None
+ except OSError:
+ LOGGER.debug("Could not establish connection")
+
+ def _notify_callback(self):
+ """Notify access denied callback."""
+ if self._callback:
+ self._callback()
+
+
+class SamsungTVLegacyBridge(SamsungTVBridge):
+ """The Bridge for Legacy TVs."""
+
+ def __init__(self, method, host, port):
+ """Initialize Bridge."""
+ super().__init__(method, host, None)
+ self.config = {
+ CONF_NAME: VALUE_CONF_NAME,
+ CONF_DESCRIPTION: VALUE_CONF_NAME,
+ CONF_ID: VALUE_CONF_ID,
+ CONF_HOST: host,
+ CONF_METHOD: method,
+ CONF_PORT: None,
+ CONF_TIMEOUT: 1,
+ }
+
+ def try_connect(self):
+ """Try to connect to the Legacy TV."""
+ config = {
+ CONF_NAME: VALUE_CONF_NAME,
+ CONF_DESCRIPTION: VALUE_CONF_NAME,
+ CONF_ID: VALUE_CONF_ID,
+ CONF_HOST: self.host,
+ CONF_METHOD: self.method,
+ CONF_PORT: None,
+ # We need this high timeout because waiting for auth popup is just an open socket
+ CONF_TIMEOUT: 31,
+ }
+ try:
+ LOGGER.debug("Try config: %s", config)
+ with Remote(config.copy()):
+ LOGGER.debug("Working config: %s", config)
+ return RESULT_SUCCESS
+ except AccessDenied:
+ LOGGER.debug("Working but denied config: %s", config)
+ return RESULT_AUTH_MISSING
+ except UnhandledResponse:
+ LOGGER.debug("Working but unsupported config: %s", config)
+ return RESULT_NOT_SUPPORTED
+ except OSError as err:
+ LOGGER.debug("Failing config: %s, error: %s", config, err)
+ return RESULT_NOT_SUCCESSFUL
+
+ def _get_remote(self):
+ """Create or return a remote control instance."""
+ if self._remote is None:
+ # We need to create a new instance to reconnect.
+ try:
+ LOGGER.debug("Create SamsungRemote")
+ self._remote = Remote(self.config.copy())
+ # This is only happening when the auth was switched to DENY
+ # A removed auth will lead to socket timeout because waiting for auth popup is just an open socket
+ except AccessDenied:
+ self._notify_callback()
+ raise
+ return self._remote
+
+ def _send_key(self, key):
+ """Send the key using legacy protocol."""
+ self._get_remote().control(key)
+
+
+class SamsungTVWSBridge(SamsungTVBridge):
+ """The Bridge for WebSocket TVs."""
+
+ def __init__(self, method, host, port, token=None):
+ """Initialize Bridge."""
+ super().__init__(method, host, port)
+ self.token = token
+ self.default_port = 8001
+
+ def try_connect(self):
+ """Try to connect to the Websocket TV."""
+ for self.port in (8001, 8002):
+ config = {
+ CONF_NAME: VALUE_CONF_NAME,
+ CONF_HOST: self.host,
+ CONF_METHOD: self.method,
+ CONF_PORT: self.port,
+ # We need this high timeout because waiting for auth popup is just an open socket
+ CONF_TIMEOUT: 31,
+ }
+
+ try:
+ LOGGER.debug("Try config: %s", config)
+ with SamsungTVWS(
+ host=self.host,
+ port=self.port,
+ token=self.token,
+ timeout=config[CONF_TIMEOUT],
+ name=config[CONF_NAME],
+ ) as remote:
+ remote.open()
+ self.token = remote.token
+ if self.token:
+ config[CONF_TOKEN] = "*****"
+ LOGGER.debug("Working config: %s", config)
+ return RESULT_SUCCESS
+ except WebSocketException:
+ LOGGER.debug("Working but unsupported config: %s", config)
+ return RESULT_NOT_SUPPORTED
+ except (OSError, ConnectionFailure) as err:
+ LOGGER.debug("Failing config: %s, error: %s", config, err)
+
+ return RESULT_NOT_SUCCESSFUL
+
+ def _send_key(self, key):
+ """Send the key using websocket protocol."""
+ if key == "KEY_POWEROFF":
+ key = "KEY_POWER"
+ self._get_remote().send_key(key)
+
+ def _get_remote(self):
+ """Create or return a remote control instance."""
+ if self._remote is None:
+ # We need to create a new instance to reconnect.
+ try:
+ LOGGER.debug("Create SamsungTVWS")
+ self._remote = SamsungTVWS(
+ host=self.host,
+ port=self.port,
+ token=self.token,
+ timeout=1,
+ name=VALUE_CONF_NAME,
+ )
+ self._remote.open()
+ # This is only happening when the auth was switched to DENY
+ # A removed auth will lead to socket timeout because waiting for auth popup is just an open socket
+ except ConnectionFailure:
+ self._notify_callback()
+ raise
+ return self._remote
diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py
index e52123297ab..95283d9606c 100644
--- a/homeassistant/components/samsungtv/config_flow.py
+++ b/homeassistant/components/samsungtv/config_flow.py
@@ -2,10 +2,7 @@
import socket
from urllib.parse import urlparse
-from samsungctl import Remote
-from samsungctl.exceptions import AccessDenied, UnhandledResponse
import voluptuous as vol
-from websocket import WebSocketException
from homeassistant import config_entries
from homeassistant.components.ssdp import (
@@ -21,23 +18,25 @@ from homeassistant.const import (
CONF_METHOD,
CONF_NAME,
CONF_PORT,
+ CONF_TOKEN,
)
# pylint:disable=unused-import
-from .const import CONF_MANUFACTURER, CONF_MODEL, DOMAIN, LOGGER
+from .bridge import SamsungTVBridge
+from .const import (
+ CONF_MANUFACTURER,
+ CONF_MODEL,
+ DOMAIN,
+ LOGGER,
+ METHOD_LEGACY,
+ METHOD_WEBSOCKET,
+ RESULT_AUTH_MISSING,
+ RESULT_NOT_SUCCESSFUL,
+ RESULT_SUCCESS,
+)
DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str})
-
-RESULT_AUTH_MISSING = "auth_missing"
-RESULT_SUCCESS = "success"
-RESULT_NOT_SUCCESSFUL = "not_successful"
-RESULT_NOT_SUPPORTED = "not_supported"
-
-SUPPORTED_METHODS = (
- {"method": "websocket", "timeout": 1},
- # We need this high timeout because waiting for auth popup is just an open socket
- {"method": "legacy", "timeout": 31},
-)
+SUPPORTED_METHODS = [METHOD_LEGACY, METHOD_WEBSOCKET]
def _get_ip(host):
@@ -59,61 +58,39 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._host = None
self._ip = None
self._manufacturer = None
- self._method = None
self._model = None
self._name = None
- self._port = None
self._title = None
self._id = None
+ self._bridge = None
def _get_entry(self):
- return self.async_create_entry(
- title=self._title,
- data={
- CONF_HOST: self._host,
- CONF_ID: self._id,
- CONF_IP_ADDRESS: self._ip,
- CONF_MANUFACTURER: self._manufacturer,
- CONF_METHOD: self._method,
- CONF_MODEL: self._model,
- CONF_NAME: self._name,
- CONF_PORT: self._port,
- },
- )
+ data = {
+ CONF_HOST: self._host,
+ CONF_ID: self._id,
+ CONF_IP_ADDRESS: self._ip,
+ CONF_MANUFACTURER: self._manufacturer,
+ CONF_METHOD: self._bridge.method,
+ CONF_MODEL: self._model,
+ CONF_NAME: self._name,
+ CONF_PORT: self._bridge.port,
+ }
+ if self._bridge.token:
+ data[CONF_TOKEN] = self._bridge.token
+ return self.async_create_entry(title=self._title, data=data,)
def _try_connect(self):
"""Try to connect and check auth."""
- for cfg in SUPPORTED_METHODS:
- config = {
- "name": "HomeAssistant",
- "description": "HomeAssistant",
- "id": "ha.component.samsung",
- "host": self._host,
- "port": self._port,
- }
- config.update(cfg)
- try:
- LOGGER.debug("Try config: %s", config)
- with Remote(config.copy()):
- LOGGER.debug("Working config: %s", config)
- self._method = cfg["method"]
- return RESULT_SUCCESS
- except AccessDenied:
- LOGGER.debug("Working but denied config: %s", config)
- return RESULT_AUTH_MISSING
- except (UnhandledResponse, WebSocketException):
- LOGGER.debug("Working but unsupported config: %s", config)
- return RESULT_NOT_SUPPORTED
- except OSError as err:
- LOGGER.debug("Failing config: %s, error: %s", config, err)
-
+ for method in SUPPORTED_METHODS:
+ self._bridge = SamsungTVBridge.get_bridge(method, self._host)
+ result = self._bridge.try_connect()
+ if result != RESULT_NOT_SUCCESSFUL:
+ return result
LOGGER.debug("No working config found")
return RESULT_NOT_SUCCESSFUL
async def async_step_import(self, user_input=None):
"""Handle configuration by yaml file."""
- self._port = user_input.get(CONF_PORT)
-
return await self.async_step_user(user_input)
async def async_step_user(self, user_input=None):
@@ -158,13 +135,14 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if self._id.startswith("uuid:"):
self._id = self._id[5:]
- config_entry = await self.async_set_unique_id(ip_address)
- if config_entry:
- config_entry.data[CONF_ID] = self._id
- config_entry.data[CONF_MANUFACTURER] = self._manufacturer
- config_entry.data[CONF_MODEL] = self._model
- self.hass.config_entries.async_update_entry(config_entry)
- return self.async_abort(reason="already_configured")
+ await self.async_set_unique_id(ip_address)
+ self._abort_if_unique_id_configured(
+ {
+ CONF_ID: self._id,
+ CONF_MANUFACTURER: self._manufacturer,
+ CONF_MODEL: self._model,
+ }
+ )
self.context["title_placeholders"] = {"model": self._model}
return await self.async_step_confirm()
@@ -190,7 +168,6 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._manufacturer = user_input.get(CONF_MANUFACTURER)
self._model = user_input.get(CONF_MODEL)
self._name = user_input.get(CONF_NAME)
- self._port = user_input.get(CONF_PORT)
self._title = self._model or self._name
await self.async_set_unique_id(self._ip)
diff --git a/homeassistant/components/samsungtv/const.py b/homeassistant/components/samsungtv/const.py
index 46f6fb59a8c..c08f07e6379 100644
--- a/homeassistant/components/samsungtv/const.py
+++ b/homeassistant/components/samsungtv/const.py
@@ -6,6 +6,18 @@ DOMAIN = "samsungtv"
DEFAULT_NAME = "Samsung TV"
+VALUE_CONF_NAME = "HomeAssistant"
+VALUE_CONF_ID = "ha.component.samsung"
+
+CONF_DESCRIPTION = "description"
CONF_MANUFACTURER = "manufacturer"
CONF_MODEL = "model"
CONF_ON_ACTION = "turn_on_action"
+
+RESULT_AUTH_MISSING = "auth_missing"
+RESULT_SUCCESS = "success"
+RESULT_NOT_SUCCESSFUL = "not_successful"
+RESULT_NOT_SUPPORTED = "not_supported"
+
+METHOD_LEGACY = "legacy"
+METHOD_WEBSOCKET = "websocket"
diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json
index 3adc3b52eb3..66f71b5c5da 100644
--- a/homeassistant/components/samsungtv/manifest.json
+++ b/homeassistant/components/samsungtv/manifest.json
@@ -3,7 +3,8 @@
"name": "Samsung Smart TV",
"documentation": "https://www.home-assistant.io/integrations/samsungtv",
"requirements": [
- "samsungctl[websocket]==0.7.1"
+ "samsungctl[websocket]==0.7.1",
+ "samsungtvws[websocket]==1.4.0"
],
"ssdp": [
{
@@ -15,4 +16,4 @@
"@escoand"
],
"config_flow": true
-}
+}
\ No newline at end of file
diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py
index 8de42d157b7..8f12341ee4a 100644
--- a/homeassistant/components/samsungtv/media_player.py
+++ b/homeassistant/components/samsungtv/media_player.py
@@ -2,9 +2,7 @@
import asyncio
from datetime import timedelta
-from samsungctl import Remote as SamsungRemote, exceptions as samsung_exceptions
import voluptuous as vol
-from websocket import WebSocketException
from homeassistant.components.media_player import DEVICE_CLASS_TV, MediaPlayerDevice
from homeassistant.components.media_player.const import (
@@ -27,6 +25,7 @@ from homeassistant.const import (
CONF_METHOD,
CONF_NAME,
CONF_PORT,
+ CONF_TOKEN,
STATE_OFF,
STATE_ON,
)
@@ -34,6 +33,7 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.script import Script
from homeassistant.util import dt as dt_util
+from .bridge import SamsungTVBridge
from .const import CONF_MANUFACTURER, CONF_MODEL, CONF_ON_ACTION, DOMAIN, LOGGER
KEY_PRESS_TIMEOUT = 1.2
@@ -71,13 +71,27 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
):
turn_on_action = hass.data[DOMAIN][ip_address][CONF_ON_ACTION]
on_script = Script(hass, turn_on_action)
- async_add_entities([SamsungTVDevice(config_entry, on_script)])
+
+ # Initialize bridge
+ data = config_entry.data.copy()
+ bridge = SamsungTVBridge.get_bridge(
+ data[CONF_METHOD], data[CONF_HOST], data[CONF_PORT], data.get(CONF_TOKEN),
+ )
+ if bridge.port is None and bridge.default_port is not None:
+ # For backward compat, set default port for websocket tv
+ data[CONF_PORT] = bridge.default_port
+ hass.config_entries.async_update_entry(config_entry, data=data)
+ bridge = SamsungTVBridge.get_bridge(
+ data[CONF_METHOD], data[CONF_HOST], data[CONF_PORT], data.get(CONF_TOKEN),
+ )
+
+ async_add_entities([SamsungTVDevice(bridge, config_entry, on_script)])
class SamsungTVDevice(MediaPlayerDevice):
"""Representation of a Samsung TV."""
- def __init__(self, config_entry, on_script):
+ def __init__(self, bridge, config_entry, on_script):
"""Initialize the Samsung device."""
self._config_entry = config_entry
self._manufacturer = config_entry.data.get(CONF_MANUFACTURER)
@@ -90,91 +104,34 @@ class SamsungTVDevice(MediaPlayerDevice):
# Assume that the TV is in Play mode
self._playing = True
self._state = None
- self._remote = None
# Mark the end of a shutdown command (need to wait 15 seconds before
# sending the next command to avoid turning the TV back ON).
self._end_of_power_off = None
- # Generate a configuration for the Samsung library
- self._config = {
- "name": "HomeAssistant",
- "description": "HomeAssistant",
- "id": "ha.component.samsung",
- "method": config_entry.data[CONF_METHOD],
- "port": config_entry.data.get(CONF_PORT),
- "host": config_entry.data[CONF_HOST],
- "timeout": 1,
- }
+ self._bridge = bridge
+ self._bridge.register_reauth_callback(self.access_denied)
+
+ def access_denied(self):
+ """Access denied callbck."""
+ LOGGER.debug("Access denied in getting remote object")
+ self.hass.add_job(
+ self.hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "reauth"}, data=self._config_entry.data,
+ )
+ )
def update(self):
"""Update state of device."""
if self._power_off_in_progress():
self._state = STATE_OFF
else:
- if self._remote is not None:
- # Close the current remote connection
- self._remote.close()
- self._remote = None
-
- try:
- self.get_remote()
- if self._remote:
- self._state = STATE_ON
- except (
- samsung_exceptions.UnhandledResponse,
- samsung_exceptions.AccessDenied,
- ):
- # We got a response so it's working.
- self._state = STATE_ON
- except (OSError, WebSocketException):
- # Different reasons, e.g. hostname not resolveable
- self._state = STATE_OFF
-
- def get_remote(self):
- """Create or return a remote control instance."""
- if self._remote is None:
- # We need to create a new instance to reconnect.
- try:
- self._remote = SamsungRemote(self._config.copy())
- # This is only happening when the auth was switched to DENY
- # A removed auth will lead to socket timeout because waiting for auth popup is just an open socket
- except samsung_exceptions.AccessDenied:
- self.hass.async_create_task(
- self.hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": "reauth"},
- data=self._config_entry.data,
- )
- )
- raise
-
- return self._remote
+ self._state = STATE_ON if self._bridge.is_on() else STATE_OFF
def send_key(self, key):
"""Send a key to the tv and handles exceptions."""
- if self._power_off_in_progress() and key not in ("KEY_POWER", "KEY_POWEROFF"):
+ if self._power_off_in_progress() and key != "KEY_POWEROFF":
LOGGER.info("TV is powering off, not sending command: %s", key)
return
- try:
- # recreate connection if connection was dead
- retry_count = 1
- for _ in range(retry_count + 1):
- try:
- self.get_remote().control(key)
- break
- except (
- samsung_exceptions.ConnectionClosed,
- BrokenPipeError,
- WebSocketException,
- ):
- # BrokenPipe can occur when the commands is sent to fast
- # WebSocketException can occur when timed out
- self._remote = None
- except (samsung_exceptions.UnhandledResponse, samsung_exceptions.AccessDenied):
- # We got a response so it's on.
- LOGGER.debug("Failed sending command %s", key, exc_info=True)
- except OSError:
- # Different reasons, e.g. hostname not resolveable
- pass
+ self._bridge.send_key(key)
def _power_off_in_progress(self):
return (
@@ -233,16 +190,9 @@ class SamsungTVDevice(MediaPlayerDevice):
"""Turn off media player."""
self._end_of_power_off = dt_util.utcnow() + timedelta(seconds=15)
- if self._config["method"] == "websocket":
- self.send_key("KEY_POWER")
- else:
- self.send_key("KEY_POWEROFF")
+ self.send_key("KEY_POWEROFF")
# Force closing of remote session to provide instant UI feedback
- try:
- self.get_remote().close()
- self._remote = None
- except OSError:
- LOGGER.debug("Could not establish connection.")
+ self._bridge.close_remote()
def volume_up(self):
"""Volume up the media player."""
diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py
index 0a7b8596248..9384c58db81 100644
--- a/homeassistant/components/script/__init__.py
+++ b/homeassistant/components/script/__init__.py
@@ -242,7 +242,9 @@ class ScriptEntity(ToggleEntity):
self.object_id = object_id
self.icon = icon
self.entity_id = ENTITY_ID_FORMAT.format(object_id)
- self.script = Script(hass, sequence, name, self.async_update_ha_state)
+ self.script = Script(
+ hass, sequence, name, self.async_update_ha_state, logger=_LOGGER
+ )
@property
def should_poll(self):
@@ -279,22 +281,15 @@ class ScriptEntity(ToggleEntity):
{ATTR_NAME: self.script.name, ATTR_ENTITY_ID: self.entity_id},
context=context,
)
- try:
- await self.script.async_run(kwargs.get(ATTR_VARIABLES), context)
- except Exception as err:
- self.script.async_log_exception(
- _LOGGER, f"Error executing script {self.entity_id}", err
- )
- raise err
+ await self.script.async_run(kwargs.get(ATTR_VARIABLES), context)
async def async_turn_off(self, **kwargs):
"""Turn script off."""
- self.script.async_stop()
+ await self.script.async_stop()
async def async_will_remove_from_hass(self):
"""Stop script and remove service when it will be removed from Home Assistant."""
- if self.script.is_running:
- self.script.async_stop()
+ await self.script.async_stop()
# remove service
self.hass.services.async_remove(DOMAIN, self.object_id)
diff --git a/homeassistant/components/script/manifest.json b/homeassistant/components/script/manifest.json
index dac37110172..ce9899f021c 100644
--- a/homeassistant/components/script/manifest.json
+++ b/homeassistant/components/script/manifest.json
@@ -3,7 +3,7 @@
"name": "Scripts",
"documentation": "https://www.home-assistant.io/integrations/script",
"requirements": [],
- "dependencies": ["group"],
+ "dependencies": [],
"codeowners": ["@home-assistant/core"],
"quality_scale": "internal"
}
diff --git a/homeassistant/components/sense/.translations/ca.json b/homeassistant/components/sense/.translations/ca.json
new file mode 100644
index 00000000000..b1a49974cbd
--- /dev/null
+++ b/homeassistant/components/sense/.translations/ca.json
@@ -0,0 +1,22 @@
+{
+ "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": {
+ "email": "Correu electr\u00f2nic",
+ "password": "Contrasenya"
+ },
+ "title": "Connexi\u00f3 amb Sense Energy Monitor"
+ }
+ },
+ "title": "Sense"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sense/.translations/de.json b/homeassistant/components/sense/.translations/de.json
new file mode 100644
index 00000000000..229b26e56bd
--- /dev/null
+++ b/homeassistant/components/sense/.translations/de.json
@@ -0,0 +1,22 @@
+{
+ "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": {
+ "email": "E-Mail-Adresse",
+ "password": "Passwort"
+ },
+ "title": "Stellen Sie eine Verbindung zu Ihrem Sense Energy Monitor her"
+ }
+ },
+ "title": "Sense"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sense/.translations/en.json b/homeassistant/components/sense/.translations/en.json
new file mode 100644
index 00000000000..32e6f48e153
--- /dev/null
+++ b/homeassistant/components/sense/.translations/en.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Device is already configured"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "invalid_auth": "Invalid authentication",
+ "unknown": "Unexpected error"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "email": "Email Address",
+ "password": "Password"
+ },
+ "title": "Connect to your Sense Energy Monitor"
+ }
+ },
+ "title": "Sense"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sense/.translations/es.json b/homeassistant/components/sense/.translations/es.json
new file mode 100644
index 00000000000..07078670ace
--- /dev/null
+++ b/homeassistant/components/sense/.translations/es.json
@@ -0,0 +1,22 @@
+{
+ "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": "Autentificaci\u00f3n inv\u00e1lida",
+ "unknown": "Error inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "email": "Direcci\u00f3n de correo electr\u00f3nico",
+ "password": "Contrase\u00f1a"
+ },
+ "title": "Conectar a tu Sense Energy Monitor"
+ }
+ },
+ "title": "Sense"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sense/.translations/it.json b/homeassistant/components/sense/.translations/it.json
new file mode 100644
index 00000000000..8bcbbb835a1
--- /dev/null
+++ b/homeassistant/components/sense/.translations/it.json
@@ -0,0 +1,22 @@
+{
+ "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": {
+ "email": "Indirizzo E-Mail",
+ "password": "Password"
+ },
+ "title": "Connettiti al tuo Sense Energy Monitor"
+ }
+ },
+ "title": "Sense"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sense/.translations/lb.json b/homeassistant/components/sense/.translations/lb.json
new file mode 100644
index 00000000000..74e7615cf5c
--- /dev/null
+++ b/homeassistant/components/sense/.translations/lb.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Apparat ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "cannot_connect": "Feeler beim verbannen, prob\u00e9iert w.e.g. nach emol.",
+ "invalid_auth": "Ong\u00eblteg Authentifikatiouns",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "email": "E-Mail Adress",
+ "password": "Passwuert"
+ },
+ "title": "Verbann d\u00e4in Sense Energie Monitor"
+ }
+ },
+ "title": "Sense"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sense/.translations/lv.json b/homeassistant/components/sense/.translations/lv.json
new file mode 100644
index 00000000000..85a6742da50
--- /dev/null
+++ b/homeassistant/components/sense/.translations/lv.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "error": {
+ "unknown": "Neparedz\u0113ta k\u013c\u016bda"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "email": "E-pasta adrese",
+ "password": "Parole"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sense/.translations/no.json b/homeassistant/components/sense/.translations/no.json
new file mode 100644
index 00000000000..70bd45558a3
--- /dev/null
+++ b/homeassistant/components/sense/.translations/no.json
@@ -0,0 +1,22 @@
+{
+ "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": {
+ "email": "E-postadresse",
+ "password": "Passord"
+ },
+ "title": "Koble til din Sense Energi Monitor"
+ }
+ },
+ "title": "Sense"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sense/.translations/ru.json b/homeassistant/components/sense/.translations/ru.json
new file mode 100644
index 00000000000..6a609a05f6d
--- /dev/null
+++ b/homeassistant/components/sense/.translations/ru.json
@@ -0,0 +1,22 @@
+{
+ "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": {
+ "email": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b",
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c"
+ },
+ "title": "Sense Energy Monitor"
+ }
+ },
+ "title": "Sense"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sense/.translations/zh-Hant.json b/homeassistant/components/sense/.translations/zh-Hant.json
new file mode 100644
index 00000000000..1d911576454
--- /dev/null
+++ b/homeassistant/components/sense/.translations/zh-Hant.json
@@ -0,0 +1,22 @@
+{
+ "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": {
+ "email": "\u96fb\u5b50\u90f5\u4ef6\u5730\u5740",
+ "password": "\u5bc6\u78bc"
+ },
+ "title": "\u9023\u7dda\u81f3 Sense \u80fd\u6e90\u76e3\u63a7"
+ }
+ },
+ "title": "Sense"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py
index ce0d3bce5dc..d7887f7ab01 100644
--- a/homeassistant/components/sense/__init__.py
+++ b/homeassistant/components/sense/__init__.py
@@ -1,4 +1,5 @@
"""Support for monitoring a Sense energy sensor."""
+import asyncio
from datetime import timedelta
import logging
@@ -9,21 +10,27 @@ from sense_energy import (
)
import voluptuous as vol
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
+from .const import (
+ ACTIVE_UPDATE_RATE,
+ DEFAULT_TIMEOUT,
+ DOMAIN,
+ SENSE_DATA,
+ SENSE_DEVICE_UPDATE,
+ SENSE_DEVICES_DATA,
+ SENSE_DISCOVERED_DEVICES_DATA,
+)
+
_LOGGER = logging.getLogger(__name__)
-ACTIVE_UPDATE_RATE = 60
-
-DEFAULT_TIMEOUT = 5
-DOMAIN = "sense"
-
-SENSE_DATA = "sense_data"
-SENSE_DEVICE_UPDATE = "sense_devices_update"
+PLATFORMS = ["binary_sensor", "sensor"]
CONFIG_SCHEMA = vol.Schema(
{
@@ -39,34 +46,114 @@ CONFIG_SCHEMA = vol.Schema(
)
-async def async_setup(hass, config):
- """Set up the Sense sensor."""
+class SenseDevicesData:
+ """Data for each sense device."""
- username = config[DOMAIN][CONF_EMAIL]
- password = config[DOMAIN][CONF_PASSWORD]
+ def __init__(self):
+ """Create."""
+ self._data_by_device = {}
+
+ def set_devices_data(self, devices):
+ """Store a device update."""
+ self._data_by_device = {}
+ for device in devices:
+ self._data_by_device[device["id"]] = device
+
+ def get_device_by_id(self, sense_device_id):
+ """Get the latest device data."""
+ return self._data_by_device.get(sense_device_id)
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+ """Set up the Sense component."""
+ hass.data.setdefault(DOMAIN, {})
+ conf = config.get(DOMAIN)
+ if not conf:
+ return True
+
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={
+ CONF_EMAIL: conf[CONF_EMAIL],
+ CONF_PASSWORD: conf[CONF_PASSWORD],
+ CONF_TIMEOUT: conf[CONF_TIMEOUT],
+ },
+ )
+ )
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Set up Sense from a config entry."""
+
+ entry_data = entry.data
+ email = entry_data[CONF_EMAIL]
+ password = entry_data[CONF_PASSWORD]
+ timeout = entry_data[CONF_TIMEOUT]
+
+ gateway = ASyncSenseable(api_timeout=timeout, wss_timeout=timeout)
+ gateway.rate_limit = ACTIVE_UPDATE_RATE
- timeout = config[DOMAIN][CONF_TIMEOUT]
try:
- hass.data[SENSE_DATA] = ASyncSenseable(api_timeout=timeout, wss_timeout=timeout)
- hass.data[SENSE_DATA].rate_limit = ACTIVE_UPDATE_RATE
- await hass.data[SENSE_DATA].authenticate(username, password)
+ await gateway.authenticate(email, password)
except SenseAuthenticationException:
_LOGGER.error("Could not authenticate with sense server")
return False
- hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config))
- hass.async_create_task(
- async_load_platform(hass, "binary_sensor", DOMAIN, {}, config)
- )
+ except SenseAPITimeoutException:
+ raise ConfigEntryNotReady
+
+ sense_devices_data = SenseDevicesData()
+ sense_discovered_devices = await gateway.get_discovered_device_data()
+
+ hass.data[DOMAIN][entry.entry_id] = {
+ SENSE_DATA: gateway,
+ SENSE_DEVICES_DATA: sense_devices_data,
+ SENSE_DISCOVERED_DEVICES_DATA: sense_discovered_devices,
+ }
+
+ for component in PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, component)
+ )
async def async_sense_update(now):
"""Retrieve latest state."""
try:
- await hass.data[SENSE_DATA].update_realtime()
- async_dispatcher_send(hass, SENSE_DEVICE_UPDATE)
+ await gateway.update_realtime()
except SenseAPITimeoutException:
_LOGGER.error("Timeout retrieving data")
- async_track_time_interval(
+ data = gateway.get_realtime()
+ if "devices" in data:
+ sense_devices_data.set_devices_data(data["devices"])
+ async_dispatcher_send(hass, f"{SENSE_DEVICE_UPDATE}-{gateway.sense_monitor_id}")
+
+ hass.data[DOMAIN][entry.entry_id][
+ "track_time_remove_callback"
+ ] = async_track_time_interval(
hass, async_sense_update, timedelta(seconds=ACTIVE_UPDATE_RATE)
)
return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Unload a config entry."""
+ unload_ok = all(
+ await asyncio.gather(
+ *[
+ hass.config_entries.async_forward_entry_unload(entry, component)
+ for component in PLATFORMS
+ ]
+ )
+ )
+ track_time_remove_callback = hass.data[DOMAIN][entry.entry_id][
+ "track_time_remove_callback"
+ ]
+ track_time_remove_callback()
+
+ if unload_ok:
+ hass.data[DOMAIN].pop(entry.entry_id)
+
+ return unload_ok
diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py
index 81f1b64c864..50fb3fd7dc7 100644
--- a/homeassistant/components/sense/binary_sensor.py
+++ b/homeassistant/components/sense/binary_sensor.py
@@ -2,69 +2,59 @@
import logging
from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.const import DEVICE_CLASS_POWER
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_registry import async_get_registry
-from . import SENSE_DATA, SENSE_DEVICE_UPDATE
+from .const import (
+ DOMAIN,
+ MDI_ICONS,
+ SENSE_DATA,
+ SENSE_DEVICE_UPDATE,
+ SENSE_DEVICES_DATA,
+ SENSE_DISCOVERED_DEVICES_DATA,
+)
_LOGGER = logging.getLogger(__name__)
-BIN_SENSOR_CLASS = "power"
-MDI_ICONS = {
- "ac": "air-conditioner",
- "aquarium": "fish",
- "car": "car-electric",
- "computer": "desktop-classic",
- "cup": "coffee",
- "dehumidifier": "water-off",
- "dishes": "dishwasher",
- "drill": "toolbox",
- "fan": "fan",
- "freezer": "fridge-top",
- "fridge": "fridge-bottom",
- "game": "gamepad-variant",
- "garage": "garage",
- "grill": "stove",
- "heat": "fire",
- "heater": "radiatior",
- "humidifier": "water",
- "kettle": "kettle",
- "leafblower": "leaf",
- "lightbulb": "lightbulb",
- "media_console": "set-top-box",
- "modem": "router-wireless",
- "outlet": "power-socket-us",
- "papershredder": "shredder",
- "printer": "printer",
- "pump": "water-pump",
- "settings": "settings",
- "skillet": "pot",
- "smartcamera": "webcam",
- "socket": "power-plug",
- "sound": "speaker",
- "stove": "stove",
- "trash": "trash-can",
- "tv": "television",
- "vacuum": "robot-vacuum",
- "washer": "washing-machine",
-}
-
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Sense binary sensor."""
- if discovery_info is None:
- return
- data = hass.data[SENSE_DATA]
+ data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DATA]
+ sense_devices_data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DEVICES_DATA]
+ sense_monitor_id = data.sense_monitor_id
- sense_devices = await data.get_discovered_device_data()
+ sense_devices = hass.data[DOMAIN][config_entry.entry_id][
+ SENSE_DISCOVERED_DEVICES_DATA
+ ]
devices = [
- SenseDevice(data, device)
+ SenseDevice(sense_devices_data, device, sense_monitor_id)
for device in sense_devices
if device["tags"]["DeviceListAllowed"] == "true"
]
+
+ await _migrate_old_unique_ids(hass, devices)
+
async_add_entities(devices)
+async def _migrate_old_unique_ids(hass, devices):
+ registry = await async_get_registry(hass)
+ for device in devices:
+ # Migration of old not so unique ids
+ old_entity_id = registry.async_get_entity_id(
+ "binary_sensor", DOMAIN, device.old_unique_id
+ )
+ if old_entity_id is not None:
+ _LOGGER.debug(
+ "Migrating unique_id from [%s] to [%s]",
+ device.old_unique_id,
+ device.unique_id,
+ )
+ registry.async_update_entity(old_entity_id, new_unique_id=device.unique_id)
+
+
def sense_to_mdi(sense_icon):
"""Convert sense icon to mdi icon."""
return "mdi:{}".format(MDI_ICONS.get(sense_icon, "power-plug"))
@@ -73,18 +63,27 @@ def sense_to_mdi(sense_icon):
class SenseDevice(BinarySensorDevice):
"""Implementation of a Sense energy device binary sensor."""
- def __init__(self, data, device):
+ def __init__(self, sense_devices_data, device, sense_monitor_id):
"""Initialize the Sense binary sensor."""
self._name = device["name"]
self._id = device["id"]
+ self._sense_monitor_id = sense_monitor_id
+ self._unique_id = f"{sense_monitor_id}-{self._id}"
self._icon = sense_to_mdi(device["icon"])
- self._data = data
+ self._sense_devices_data = sense_devices_data
self._undo_dispatch_subscription = None
+ self._state = None
+ self._available = False
@property
def is_on(self):
"""Return true if the binary sensor is on."""
- return self._name in self._data.active_devices
+ return self._state
+
+ @property
+ def available(self):
+ """Return the availability of the binary sensor."""
+ return self._available
@property
def name(self):
@@ -93,7 +92,12 @@ class SenseDevice(BinarySensorDevice):
@property
def unique_id(self):
- """Return the id of the binary sensor."""
+ """Return the unique id of the binary sensor."""
+ return self._unique_id
+
+ @property
+ def old_unique_id(self):
+ """Return the old not so unique id of the binary sensor."""
return self._id
@property
@@ -104,7 +108,7 @@ class SenseDevice(BinarySensorDevice):
@property
def device_class(self):
"""Return the device class of the binary sensor."""
- return BIN_SENSOR_CLASS
+ return DEVICE_CLASS_POWER
@property
def should_poll(self):
@@ -113,17 +117,20 @@ class SenseDevice(BinarySensorDevice):
async def async_added_to_hass(self):
"""Register callbacks."""
-
- @callback
- def update():
- """Update the state."""
- self.async_schedule_update_ha_state(True)
-
self._undo_dispatch_subscription = async_dispatcher_connect(
- self.hass, SENSE_DEVICE_UPDATE, update
+ self.hass,
+ f"{SENSE_DEVICE_UPDATE}-{self._sense_monitor_id}",
+ self._async_update_from_data,
)
async def async_will_remove_from_hass(self):
"""Undo subscription."""
if self._undo_dispatch_subscription:
self._undo_dispatch_subscription()
+
+ @callback
+ def _async_update_from_data(self):
+ """Get the latest data, update state. Must not do I/O."""
+ self._available = True
+ self._state = bool(self._sense_devices_data.get_device_by_id(self._id))
+ self.async_write_ha_state()
diff --git a/homeassistant/components/sense/config_flow.py b/homeassistant/components/sense/config_flow.py
new file mode 100644
index 00000000000..68bbb9ed932
--- /dev/null
+++ b/homeassistant/components/sense/config_flow.py
@@ -0,0 +1,75 @@
+"""Config flow for Sense integration."""
+import logging
+
+from sense_energy import (
+ ASyncSenseable,
+ SenseAPITimeoutException,
+ SenseAuthenticationException,
+)
+import voluptuous as vol
+
+from homeassistant import config_entries, core
+from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT
+
+from .const import ACTIVE_UPDATE_RATE, DEFAULT_TIMEOUT
+
+from .const import DOMAIN # pylint:disable=unused-import; pylint:disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_EMAIL): str,
+ vol.Required(CONF_PASSWORD): str,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int),
+ }
+)
+
+
+async def validate_input(hass: core.HomeAssistant, data):
+ """Validate the user input allows us to connect.
+
+ Data has the keys from DATA_SCHEMA with values provided by the user.
+ """
+ timeout = data[CONF_TIMEOUT]
+
+ gateway = ASyncSenseable(api_timeout=timeout, wss_timeout=timeout)
+ gateway.rate_limit = ACTIVE_UPDATE_RATE
+ await gateway.authenticate(data[CONF_EMAIL], data[CONF_PASSWORD])
+
+ # Return info that you want to store in the config entry.
+ return {"title": data[CONF_EMAIL]}
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Sense."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ async def async_step_user(self, user_input=None):
+ """Handle the initial step."""
+ errors = {}
+ if user_input is not None:
+ try:
+ info = await validate_input(self.hass, user_input)
+ await self.async_set_unique_id(user_input[CONF_EMAIL])
+ return self.async_create_entry(title=info["title"], data=user_input)
+ except SenseAPITimeoutException:
+ errors["base"] = "cannot_connect"
+ except SenseAuthenticationException:
+ errors["base"] = "invalid_auth"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ return self.async_show_form(
+ step_id="user", data_schema=DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_import(self, user_input):
+ """Handle import."""
+ await self.async_set_unique_id(user_input[CONF_EMAIL])
+ self._abort_if_unique_id_configured()
+
+ return await self.async_step_user(user_input)
diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py
new file mode 100644
index 00000000000..619956903f2
--- /dev/null
+++ b/homeassistant/components/sense/const.py
@@ -0,0 +1,59 @@
+"""Constants for monitoring a Sense energy sensor."""
+DOMAIN = "sense"
+DEFAULT_TIMEOUT = 10
+ACTIVE_UPDATE_RATE = 60
+DEFAULT_NAME = "Sense"
+SENSE_DATA = "sense_data"
+SENSE_DEVICE_UPDATE = "sense_devices_update"
+SENSE_DEVICES_DATA = "sense_devices_data"
+SENSE_DISCOVERED_DEVICES_DATA = "sense_discovered_devices"
+
+ACTIVE_NAME = "Energy"
+ACTIVE_TYPE = "active"
+
+CONSUMPTION_NAME = "Usage"
+CONSUMPTION_ID = "usage"
+PRODUCTION_NAME = "Production"
+PRODUCTION_ID = "production"
+
+ICON = "mdi:flash"
+
+MDI_ICONS = {
+ "ac": "air-conditioner",
+ "aquarium": "fish",
+ "car": "car-electric",
+ "computer": "desktop-classic",
+ "cup": "coffee",
+ "dehumidifier": "water-off",
+ "dishes": "dishwasher",
+ "drill": "toolbox",
+ "fan": "fan",
+ "freezer": "fridge-top",
+ "fridge": "fridge-bottom",
+ "game": "gamepad-variant",
+ "garage": "garage",
+ "grill": "stove",
+ "heat": "fire",
+ "heater": "radiatior",
+ "humidifier": "water",
+ "kettle": "kettle",
+ "leafblower": "leaf",
+ "lightbulb": "lightbulb",
+ "media_console": "set-top-box",
+ "modem": "router-wireless",
+ "outlet": "power-socket-us",
+ "papershredder": "shredder",
+ "printer": "printer",
+ "pump": "water-pump",
+ "settings": "settings",
+ "skillet": "pot",
+ "smartcamera": "webcam",
+ "socket": "power-plug",
+ "solar_alt": "solar-power",
+ "sound": "speaker",
+ "stove": "stove",
+ "trash": "trash-can",
+ "tv": "television",
+ "vacuum": "robot-vacuum",
+ "washer": "washing-machine",
+}
diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json
index e27d4bb72f6..c07e1e4f5c3 100644
--- a/homeassistant/components/sense/manifest.json
+++ b/homeassistant/components/sense/manifest.json
@@ -2,7 +2,12 @@
"domain": "sense",
"name": "Sense",
"documentation": "https://www.home-assistant.io/integrations/sense",
- "requirements": ["sense_energy==0.7.0"],
+ "requirements": [
+ "sense_energy==0.7.1"
+ ],
"dependencies": [],
- "codeowners": ["@kbickar"]
+ "codeowners": [
+ "@kbickar"
+ ],
+ "config_flow": true
}
diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py
index d177a480ddf..6fe7b59c46c 100644
--- a/homeassistant/components/sense/sensor.py
+++ b/homeassistant/components/sense/sensor.py
@@ -4,24 +4,32 @@ import logging
from sense_energy import SenseAPITimeoutException
-from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT
+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
from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle
-from . import SENSE_DATA
-
-_LOGGER = logging.getLogger(__name__)
-
-ACTIVE_NAME = "Energy"
-ACTIVE_TYPE = "active"
-
-CONSUMPTION_NAME = "Usage"
-
-ICON = "mdi:flash"
+from .const import (
+ ACTIVE_NAME,
+ ACTIVE_TYPE,
+ CONSUMPTION_ID,
+ CONSUMPTION_NAME,
+ DOMAIN,
+ ICON,
+ MDI_ICONS,
+ PRODUCTION_ID,
+ PRODUCTION_NAME,
+ SENSE_DATA,
+ SENSE_DEVICE_UPDATE,
+ SENSE_DEVICES_DATA,
+ SENSE_DISCOVERED_DEVICES_DATA,
+)
MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=300)
-PRODUCTION_NAME = "Production"
+
+_LOGGER = logging.getLogger(__name__)
class SensorConfig:
@@ -34,8 +42,10 @@ class SensorConfig:
# Sensor types/ranges
-SENSOR_TYPES = {
- "active": SensorConfig(ACTIVE_NAME, ACTIVE_TYPE),
+ACTIVE_SENSOR_TYPE = SensorConfig(ACTIVE_NAME, ACTIVE_TYPE)
+
+# Sensor types/ranges
+TRENDS_SENSOR_TYPES = {
"daily": SensorConfig("Daily", "DAY"),
"weekly": SensorConfig("Weekly", "WEEK"),
"monthly": SensorConfig("Monthly", "MONTH"),
@@ -43,56 +53,95 @@ SENSOR_TYPES = {
}
# Production/consumption variants
-SENSOR_VARIANTS = [PRODUCTION_NAME.lower(), CONSUMPTION_NAME.lower()]
+SENSOR_VARIANTS = [PRODUCTION_ID, CONSUMPTION_ID]
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+def sense_to_mdi(sense_icon):
+ """Convert sense icon to mdi icon."""
+ return "mdi:{}".format(MDI_ICONS.get(sense_icon, "power-plug"))
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Sense sensor."""
- if discovery_info is None:
- return
- data = hass.data[SENSE_DATA]
+ data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DATA]
+ sense_devices_data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DEVICES_DATA]
@Throttle(MIN_TIME_BETWEEN_DAILY_UPDATES)
async def update_trends():
"""Update the daily power usage."""
await data.update_trend_data()
- async def update_active():
- """Update the active power usage."""
- await data.update_realtime()
+ sense_monitor_id = data.sense_monitor_id
+ sense_devices = hass.data[DOMAIN][config_entry.entry_id][
+ SENSE_DISCOVERED_DEVICES_DATA
+ ]
+ await data.update_trend_data()
- devices = []
- for typ in SENSOR_TYPES.values():
+ devices = [
+ SenseEnergyDevice(sense_devices_data, device, sense_monitor_id)
+ for device in sense_devices
+ if device["tags"]["DeviceListAllowed"] == "true"
+ ]
+
+ for var in SENSOR_VARIANTS:
+ name = ACTIVE_SENSOR_TYPE.name
+ sensor_type = ACTIVE_SENSOR_TYPE.sensor_type
+ is_production = var == PRODUCTION_ID
+
+ unique_id = f"{sense_monitor_id}-active-{var}"
+ devices.append(
+ SenseActiveSensor(
+ data, name, sensor_type, is_production, sense_monitor_id, var, unique_id
+ )
+ )
+
+ for type_id in TRENDS_SENSOR_TYPES:
+ typ = TRENDS_SENSOR_TYPES[type_id]
for var in SENSOR_VARIANTS:
name = typ.name
sensor_type = typ.sensor_type
- is_production = var == PRODUCTION_NAME.lower()
- if sensor_type == ACTIVE_TYPE:
- update_call = update_active
- else:
- update_call = update_trends
- devices.append(Sense(data, name, sensor_type, is_production, update_call))
+ is_production = var == PRODUCTION_ID
+
+ unique_id = f"{sense_monitor_id}-{type_id}-{var}"
+ devices.append(
+ SenseTrendsSensor(
+ data,
+ name,
+ sensor_type,
+ is_production,
+ update_trends,
+ var,
+ unique_id,
+ )
+ )
async_add_entities(devices)
-class Sense(Entity):
+class SenseActiveSensor(Entity):
"""Implementation of a Sense energy sensor."""
- def __init__(self, data, name, sensor_type, is_production, update_call):
+ def __init__(
+ self,
+ data,
+ name,
+ sensor_type,
+ is_production,
+ sense_monitor_id,
+ sensor_id,
+ unique_id,
+ ):
"""Initialize the Sense sensor."""
name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME
self._name = f"{name} {name_type}"
+ self._unique_id = unique_id
+ self._available = False
self._data = data
+ self._sense_monitor_id = sense_monitor_id
self._sensor_type = sensor_type
- self.update_sensor = update_call
self._is_production = is_production
self._state = None
-
- if sensor_type == ACTIVE_TYPE:
- self._unit_of_measurement = POWER_WATT
- else:
- self._unit_of_measurement = ENERGY_KILO_WATT_HOUR
+ self._undo_dispatch_subscription = None
@property
def name(self):
@@ -104,6 +153,89 @@ class Sense(Entity):
"""Return the state of the sensor."""
return self._state
+ @property
+ def available(self):
+ """Return the availability of the sensor."""
+ return self._available
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return POWER_WATT
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ @property
+ def unique_id(self):
+ """Return the unique id."""
+ return self._unique_id
+
+ @property
+ def should_poll(self):
+ """Return the device should not poll for updates."""
+ return False
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self._undo_dispatch_subscription = async_dispatcher_connect(
+ self.hass,
+ f"{SENSE_DEVICE_UPDATE}-{self._sense_monitor_id}",
+ self._async_update_from_data,
+ )
+
+ async def async_will_remove_from_hass(self):
+ """Undo subscription."""
+ if self._undo_dispatch_subscription:
+ self._undo_dispatch_subscription()
+
+ @callback
+ def _async_update_from_data(self):
+ """Update the sensor from the data. Must not do I/O."""
+ self._state = round(
+ self._data.active_solar_power
+ if self._is_production
+ else self._data.active_power
+ )
+ self._available = True
+ self.async_write_ha_state()
+
+
+class SenseTrendsSensor(Entity):
+ """Implementation of a Sense energy sensor."""
+
+ def __init__(
+ self, data, name, sensor_type, is_production, update_call, sensor_id, unique_id
+ ):
+ """Initialize the Sense sensor."""
+ name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME
+ self._name = f"{name} {name_type}"
+ self._unique_id = unique_id
+ self._available = False
+ self._data = data
+ self._sensor_type = sensor_type
+ self.update_sensor = update_call
+ self._is_production = is_production
+ self._state = None
+ self._unit_of_measurement = ENERGY_KILO_WATT_HOUR
+
+ @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 available(self):
+ """Return the availability of the sensor."""
+ return self._available
+
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any."""
@@ -114,6 +246,11 @@ class Sense(Entity):
"""Icon to use in the frontend, if any."""
return ICON
+ @property
+ def unique_id(self):
+ """Return the unique id."""
+ return self._unique_id
+
async def async_update(self):
"""Get the latest data, update state."""
@@ -123,11 +260,86 @@ class Sense(Entity):
_LOGGER.error("Timeout retrieving data")
return
- if self._sensor_type == ACTIVE_TYPE:
- if self._is_production:
- self._state = round(self._data.active_solar_power)
- else:
- self._state = round(self._data.active_power)
+ state = self._data.get_trend(self._sensor_type, self._is_production)
+ self._state = round(state, 1)
+ self._available = True
+
+
+class SenseEnergyDevice(Entity):
+ """Implementation of a Sense energy device."""
+
+ def __init__(self, sense_devices_data, device, sense_monitor_id):
+ """Initialize the Sense binary sensor."""
+ self._name = f"{device['name']} {CONSUMPTION_NAME}"
+ self._id = device["id"]
+ self._available = False
+ self._sense_monitor_id = sense_monitor_id
+ self._unique_id = f"{sense_monitor_id}-{self._id}-{CONSUMPTION_ID}"
+ self._icon = sense_to_mdi(device["icon"])
+ self._sense_devices_data = sense_devices_data
+ self._undo_dispatch_subscription = None
+ self._state = None
+
+ @property
+ def state(self):
+ """Return the wattage of the sensor."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return the availability of the sensor."""
+ return self._available
+
+ @property
+ def name(self):
+ """Return the name of the power sensor."""
+ return self._name
+
+ @property
+ def unique_id(self):
+ """Return the unique id of the power sensor."""
+ return self._unique_id
+
+ @property
+ def icon(self):
+ """Return the icon of the power sensor."""
+ return self._icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ return POWER_WATT
+
+ @property
+ def device_class(self):
+ """Return the device class of the power sensor."""
+ return DEVICE_CLASS_POWER
+
+ @property
+ def should_poll(self):
+ """Return the device should not poll for updates."""
+ return False
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self._undo_dispatch_subscription = async_dispatcher_connect(
+ self.hass,
+ f"{SENSE_DEVICE_UPDATE}-{self._sense_monitor_id}",
+ self._async_update_from_data,
+ )
+
+ async def async_will_remove_from_hass(self):
+ """Undo subscription."""
+ if self._undo_dispatch_subscription:
+ self._undo_dispatch_subscription()
+
+ @callback
+ def _async_update_from_data(self):
+ """Get the latest data, update state. Must not do I/O."""
+ device_data = self._sense_devices_data.get_device_by_id(self._id)
+ if not device_data or "w" not in device_data:
+ self._state = 0
else:
- state = self._data.get_trend(self._sensor_type, self._is_production)
- self._state = round(state, 1)
+ self._state = int(device_data["w"])
+ self._available = True
+ self.async_write_ha_state()
diff --git a/homeassistant/components/sense/strings.json b/homeassistant/components/sense/strings.json
new file mode 100644
index 00000000000..d3af47b5378
--- /dev/null
+++ b/homeassistant/components/sense/strings.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "title": "Sense",
+ "step": {
+ "user": {
+ "title": "Connect to your Sense Energy Monitor",
+ "data": {
+ "email": "Email Address",
+ "password": "Password"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "Failed to connect, please try again",
+ "invalid_auth": "Invalid authentication",
+ "unknown": "Unexpected error"
+ },
+ "abort": {
+ "already_configured": "Device is already configured"
+ }
+ }
+}
diff --git a/homeassistant/components/sensehat/sensor.py b/homeassistant/components/sensehat/sensor.py
index 980c23f8555..29aa4af967e 100644
--- a/homeassistant/components/sensehat/sensor.py
+++ b/homeassistant/components/sensehat/sensor.py
@@ -7,7 +7,12 @@ from sense_hat import SenseHat
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_DISPLAY_OPTIONS, CONF_NAME, TEMP_CELSIUS
+from homeassistant.const import (
+ CONF_DISPLAY_OPTIONS,
+ CONF_NAME,
+ TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
+)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle
@@ -21,7 +26,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
SENSOR_TYPES = {
"temperature": ["temperature", TEMP_CELSIUS],
- "humidity": ["humidity", "%"],
+ "humidity": ["humidity", UNIT_PERCENTAGE],
"pressure": ["pressure", "mb"],
}
diff --git a/homeassistant/components/serial_pm/sensor.py b/homeassistant/components/serial_pm/sensor.py
index 75587e4eab7..2e7604ee97d 100644
--- a/homeassistant/components/serial_pm/sensor.py
+++ b/homeassistant/components/serial_pm/sensor.py
@@ -5,7 +5,7 @@ from pmsensor import serial_pm as pm
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_NAME
+from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -79,7 +79,7 @@ class ParticulateMatterSensor(Entity):
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any."""
- return "µg/m³"
+ return CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
def update(self):
"""Read from sensor and update the state."""
diff --git a/homeassistant/components/shopping_list/.translations/en.json b/homeassistant/components/shopping_list/.translations/en.json
new file mode 100644
index 00000000000..6a22409e8c6
--- /dev/null
+++ b/homeassistant/components/shopping_list/.translations/en.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "The shopping list is already configured."
+ },
+ "step": {
+ "user": {
+ "description": "Do you want to configure the shopping list?",
+ "title": "Shopping List"
+ }
+ },
+ "title": "Shopping List"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py
index 50d317c9095..11f61d6d626 100644
--- a/homeassistant/components/shopping_list/__init__.py
+++ b/homeassistant/components/shopping_list/__init__.py
@@ -1,10 +1,10 @@
"""Support to manage a shopping list."""
-import asyncio
import logging
import uuid
import voluptuous as vol
+from homeassistant import config_entries
from homeassistant.components import http, websocket_api
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.const import HTTP_BAD_REQUEST, HTTP_NOT_FOUND
@@ -12,9 +12,10 @@ from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.util.json import load_json, save_json
+from .const import DOMAIN
+
ATTR_NAME = "name"
-DOMAIN = "shopping_list"
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA)
EVENT = "shopping_list_updated"
@@ -53,20 +54,32 @@ SCHEMA_WEBSOCKET_CLEAR_ITEMS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
)
-@asyncio.coroutine
-def async_setup(hass, config):
+async def async_setup(hass, config):
"""Initialize the shopping list."""
- @asyncio.coroutine
- def add_item_service(call):
+ if DOMAIN not in config:
+ return True
+
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
+ )
+ )
+
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Set up shopping list from config flow."""
+
+ async def add_item_service(call):
"""Add an item with `name`."""
data = hass.data[DOMAIN]
name = call.data.get(ATTR_NAME)
if name is not None:
data.async_add(name)
- @asyncio.coroutine
- def complete_item_service(call):
+ async def complete_item_service(call):
"""Mark the item provided via `name` as completed."""
data = hass.data[DOMAIN]
name = call.data.get(ATTR_NAME)
@@ -80,7 +93,7 @@ def async_setup(hass, config):
data.async_update(item["id"], {"name": name, "complete": True})
data = hass.data[DOMAIN] = ShoppingData(hass)
- yield from data.async_load()
+ await data.async_load()
hass.services.async_register(
DOMAIN, SERVICE_ADD_ITEM, add_item_service, schema=SERVICE_ITEM_SCHEMA
@@ -206,8 +219,7 @@ class CreateShoppingListItemView(http.HomeAssistantView):
name = "api:shopping_list:item"
@RequestDataValidator(vol.Schema({vol.Required("name"): str}))
- @asyncio.coroutine
- def post(self, request, data):
+ async def post(self, request, data):
"""Create a new shopping list item."""
item = request.app["hass"].data[DOMAIN].async_add(data["name"])
request.app["hass"].bus.async_fire(EVENT)
@@ -241,7 +253,7 @@ def websocket_handle_items(hass, connection, msg):
def websocket_handle_add(hass, connection, msg):
"""Handle add item to shopping_list."""
item = hass.data[DOMAIN].async_add(msg["name"])
- hass.bus.async_fire(EVENT)
+ hass.bus.async_fire(EVENT, {"action": "add", "item": item})
connection.send_message(websocket_api.result_message(msg["id"], item))
@@ -255,7 +267,7 @@ async def websocket_handle_update(hass, connection, msg):
try:
item = hass.data[DOMAIN].async_update(item_id, data)
- hass.bus.async_fire(EVENT)
+ hass.bus.async_fire(EVENT, {"action": "update", "item": item})
connection.send_message(websocket_api.result_message(msg_id, item))
except KeyError:
connection.send_message(
@@ -267,5 +279,5 @@ async def websocket_handle_update(hass, connection, msg):
def websocket_handle_clear(hass, connection, msg):
"""Handle clearing shopping_list items."""
hass.data[DOMAIN].async_clear_completed()
- hass.bus.async_fire(EVENT)
+ hass.bus.async_fire(EVENT, {"action": "clear"})
connection.send_message(websocket_api.result_message(msg["id"]))
diff --git a/homeassistant/components/shopping_list/config_flow.py b/homeassistant/components/shopping_list/config_flow.py
new file mode 100644
index 00000000000..974174640be
--- /dev/null
+++ b/homeassistant/components/shopping_list/config_flow.py
@@ -0,0 +1,24 @@
+"""Config flow to configure ShoppingList component."""
+from homeassistant import config_entries
+
+from .const import DOMAIN
+
+
+class ShoppingListFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
+ """Config flow for ShoppingList component."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow initialized by the user."""
+ # Check if already configured
+ await self.async_set_unique_id(DOMAIN)
+ self._abort_if_unique_id_configured()
+
+ if user_input is not None:
+ return self.async_create_entry(title="Shopping List", data=user_input)
+
+ return self.async_show_form(step_id="user")
+
+ async_step_import = async_step_user
diff --git a/homeassistant/components/shopping_list/const.py b/homeassistant/components/shopping_list/const.py
new file mode 100644
index 00000000000..4878d317780
--- /dev/null
+++ b/homeassistant/components/shopping_list/const.py
@@ -0,0 +1,2 @@
+"""All constants related to the shopping list component."""
+DOMAIN = "shopping_list"
diff --git a/homeassistant/components/shopping_list/manifest.json b/homeassistant/components/shopping_list/manifest.json
index 0c8b66b9a03..ad060f16756 100644
--- a/homeassistant/components/shopping_list/manifest.json
+++ b/homeassistant/components/shopping_list/manifest.json
@@ -5,5 +5,6 @@
"requirements": [],
"dependencies": ["http"],
"codeowners": [],
+ "config_flow": true,
"quality_scale": "internal"
}
diff --git a/homeassistant/components/shopping_list/strings.json b/homeassistant/components/shopping_list/strings.json
new file mode 100644
index 00000000000..9e56dd7eaa4
--- /dev/null
+++ b/homeassistant/components/shopping_list/strings.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "title": "Shopping List",
+ "step": {
+ "user": {
+ "title": "Shopping List",
+ "description": "Do you want to configure the shopping list?"
+ }
+ },
+ "abort": {
+ "already_configured": "The shopping list is already configured."
+ }
+ }
+}
diff --git a/homeassistant/components/sht31/sensor.py b/homeassistant/components/sht31/sensor.py
index 8a520377896..3a66b47688c 100644
--- a/homeassistant/components/sht31/sensor.py
+++ b/homeassistant/components/sht31/sensor.py
@@ -13,6 +13,7 @@ from homeassistant.const import (
CONF_NAME,
PRECISION_TENTHS,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -140,7 +141,7 @@ class SHTSensorHumidity(SHTSensor):
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
- return "%"
+ return UNIT_PERCENTAGE
def update(self):
"""Fetch humidity from the sensor."""
diff --git a/homeassistant/components/sighthound/image_processing.py b/homeassistant/components/sighthound/image_processing.py
index 175b1edc4c6..7e9e789423e 100644
--- a/homeassistant/components/sighthound/image_processing.py
+++ b/homeassistant/components/sighthound/image_processing.py
@@ -1,6 +1,9 @@
"""Person detection using Sighthound cloud service."""
+import io
import logging
+from pathlib import Path
+from PIL import Image, ImageDraw, UnidentifiedImageError
import simplehound.core as hound
import voluptuous as vol
@@ -14,6 +17,8 @@ from homeassistant.components.image_processing import (
from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY
from homeassistant.core import split_entity_id
import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+from homeassistant.util.pil import draw_box
_LOGGER = logging.getLogger(__name__)
@@ -22,6 +27,9 @@ EVENT_PERSON_DETECTED = "sighthound.person_detected"
ATTR_BOUNDING_BOX = "bounding_box"
ATTR_PEOPLE = "people"
CONF_ACCOUNT_TYPE = "account_type"
+CONF_SAVE_FILE_FOLDER = "save_file_folder"
+CONF_SAVE_TIMESTAMPTED_FILE = "save_timestamped_file"
+DATETIME_FORMAT = "%Y-%m-%d_%H:%M:%S"
DEV = "dev"
PROD = "prod"
@@ -29,6 +37,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_API_KEY): cv.string,
vol.Optional(CONF_ACCOUNT_TYPE, default=DEV): vol.In([DEV, PROD]),
+ vol.Optional(CONF_SAVE_FILE_FOLDER): cv.isdir,
+ vol.Optional(CONF_SAVE_TIMESTAMPTED_FILE, default=False): cv.boolean,
}
)
@@ -45,10 +55,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
_LOGGER.error("Sighthound error %s setup aborted", exc)
return
+ save_file_folder = config.get(CONF_SAVE_FILE_FOLDER)
+ if save_file_folder:
+ save_file_folder = Path(save_file_folder)
+
entities = []
for camera in config[CONF_SOURCE]:
sighthound = SighthoundEntity(
- api, camera[CONF_ENTITY_ID], camera.get(CONF_NAME)
+ api,
+ camera[CONF_ENTITY_ID],
+ camera.get(CONF_NAME),
+ save_file_folder,
+ config[CONF_SAVE_TIMESTAMPTED_FILE],
)
entities.append(sighthound)
add_entities(entities)
@@ -57,7 +75,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
class SighthoundEntity(ImageProcessingEntity):
"""Create a sighthound entity."""
- def __init__(self, api, camera_entity, name):
+ def __init__(
+ self, api, camera_entity, name, save_file_folder, save_timestamped_file
+ ):
"""Init."""
self._api = api
self._camera = camera_entity
@@ -67,20 +87,27 @@ class SighthoundEntity(ImageProcessingEntity):
camera_name = split_entity_id(camera_entity)[1]
self._name = f"sighthound_{camera_name}"
self._state = None
+ self._last_detection = None
self._image_width = None
self._image_height = None
+ self._save_file_folder = save_file_folder
+ self._save_timestamped_file = save_timestamped_file
def process_image(self, image):
"""Process an image."""
detections = self._api.detect(image)
people = hound.get_people(detections)
self._state = len(people)
+ if self._state > 0:
+ self._last_detection = dt_util.now().strftime(DATETIME_FORMAT)
metadata = hound.get_metadata(detections)
self._image_width = metadata["image_width"]
self._image_height = metadata["image_height"]
for person in people:
self.fire_person_detected_event(person)
+ if self._save_file_folder and self._state > 0:
+ self.save_image(image, people, self._save_file_folder)
def fire_person_detected_event(self, person):
"""Send event with detected total_persons."""
@@ -94,6 +121,29 @@ class SighthoundEntity(ImageProcessingEntity):
},
)
+ def save_image(self, image, people, directory):
+ """Save a timestamped image with bounding boxes around targets."""
+ try:
+ img = Image.open(io.BytesIO(bytearray(image))).convert("RGB")
+ except UnidentifiedImageError:
+ _LOGGER.warning("Sighthound unable to process image, bad data")
+ return
+ draw = ImageDraw.Draw(img)
+
+ for person in people:
+ box = hound.bbox_to_tf_style(
+ person["boundingBox"], self._image_width, self._image_height
+ )
+ draw_box(draw, box, self._image_width, self._image_height)
+
+ latest_save_path = directory / f"{self._name}_latest.jpg"
+ img.save(latest_save_path)
+
+ if self._save_timestamped_file:
+ timestamp_save_path = directory / f"{self._name}_{self._last_detection}.jpg"
+ img.save(timestamp_save_path)
+ _LOGGER.info("Sighthound saved file %s", timestamp_save_path)
+
@property
def camera_entity(self):
"""Return camera entity id from process pictures."""
@@ -118,3 +168,11 @@ class SighthoundEntity(ImageProcessingEntity):
def unit_of_measurement(self):
"""Return the unit of measurement."""
return ATTR_PEOPLE
+
+ @property
+ def device_state_attributes(self):
+ """Return the attributes."""
+ attr = {}
+ if self._last_detection:
+ attr["last_person"] = self._last_detection
+ return attr
diff --git a/homeassistant/components/simplisafe/.translations/es.json b/homeassistant/components/simplisafe/.translations/es.json
index 802a2e6b842..815aa6be742 100644
--- a/homeassistant/components/simplisafe/.translations/es.json
+++ b/homeassistant/components/simplisafe/.translations/es.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "already_configured": "Esta cuenta SimpliSafe ya est\u00e1 en uso."
+ },
"error": {
"identifier_exists": "Cuenta ya registrada",
"invalid_credentials": "Credenciales no v\u00e1lidas"
diff --git a/homeassistant/components/simplisafe/.translations/it.json b/homeassistant/components/simplisafe/.translations/it.json
index 6f0e403a356..f153ec36959 100644
--- a/homeassistant/components/simplisafe/.translations/it.json
+++ b/homeassistant/components/simplisafe/.translations/it.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "already_configured": "Questo account SimpliSafe \u00e8 gi\u00e0 in uso."
+ },
"error": {
"identifier_exists": "Account gi\u00e0 registrato",
"invalid_credentials": "Credenziali non valide"
diff --git a/homeassistant/components/simplisafe/.translations/lb.json b/homeassistant/components/simplisafe/.translations/lb.json
index 94c451a49db..c0e9faf08f6 100644
--- a/homeassistant/components/simplisafe/.translations/lb.json
+++ b/homeassistant/components/simplisafe/.translations/lb.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "already_configured": "D\u00ebse SimpliSafe Kont g\u00ebtt scho benotzt."
+ },
"error": {
"identifier_exists": "Konto ass scho registr\u00e9iert",
"invalid_credentials": "Ong\u00eblteg Login Informatioune"
diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py
index add37cb0f1e..8c75ed5d9f5 100644
--- a/homeassistant/components/simplisafe/__init__.py
+++ b/homeassistant/components/simplisafe/__init__.py
@@ -3,9 +3,11 @@ import asyncio
import logging
from simplipy import API
-from simplipy.errors import InvalidCredentialsError, SimplipyError, WebsocketError
+from simplipy.errors import InvalidCredentialsError, SimplipyError
from simplipy.websocket import (
EVENT_CAMERA_MOTION_DETECTED,
+ EVENT_CONNECTION_LOST,
+ EVENT_CONNECTION_RESTORED,
EVENT_DOORBELL_DETECTED,
EVENT_ENTRY_DETECTED,
EVENT_LOCK_LOCKED,
@@ -34,13 +36,12 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.event import async_call_later, async_track_time_interval
+from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.service import (
async_register_admin_service,
verify_domain_control,
)
-from .config_flow import configured_instances
from .const import (
ATTR_ALARM_DURATION,
ATTR_ALARM_VOLUME,
@@ -68,7 +69,6 @@ EVENT_SIMPLISAFE_EVENT = "SIMPLISAFE_EVENT"
EVENT_SIMPLISAFE_NOTIFICATION = "SIMPLISAFE_NOTIFICATION"
DEFAULT_SOCKET_MIN_RETRY = 15
-DEFAULT_WATCHDOG_SECONDS = 5 * 60
WEBSOCKET_EVENTS_REQUIRING_SERIAL = [EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED]
WEBSOCKET_EVENTS_TO_TRIGGER_HASS_EVENT = [
@@ -184,9 +184,6 @@ async def async_setup(hass, config):
conf = config[DOMAIN]
for account in conf[CONF_ACCOUNTS]:
- if account[CONF_USERNAME] in configured_instances(hass):
- continue
-
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
@@ -204,6 +201,11 @@ async def async_setup(hass, config):
async def async_setup_entry(hass, config_entry):
"""Set up SimpliSafe as config entry."""
+ if not config_entry.unique_id:
+ hass.config_entries.async_update_entry(
+ config_entry, unique_id=config_entry.data[CONF_USERNAME]
+ )
+
_verify_domain_control = verify_domain_control(hass, DOMAIN)
websession = aiohttp_client.async_get_clientsession(hass)
@@ -333,46 +335,12 @@ class SimpliSafeWebsocket:
"""Initialize."""
self._hass = hass
self._websocket = websocket
- self._websocket_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY
- self._websocket_reconnect_underway = False
- self._websocket_watchdog_listener = None
self.last_events = {}
- async def _async_attempt_websocket_connect(self):
- """Attempt to connect to the websocket (retrying later on fail)."""
- self._websocket_reconnect_underway = True
-
- try:
- await self._websocket.async_connect()
- except WebsocketError as err:
- _LOGGER.error("Error with the websocket connection: %s", err)
- self._websocket_reconnect_delay = min(
- 2 * self._websocket_reconnect_delay, 480
- )
- async_call_later(
- self._hass,
- self._websocket_reconnect_delay,
- self.async_websocket_connect,
- )
- else:
- self._websocket_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY
- self._websocket_reconnect_underway = False
-
- async def _async_websocket_reconnect(self, event_time):
- """Forcibly disconnect from and reconnect to the websocket."""
- _LOGGER.debug("Websocket watchdog expired; forcing socket reconnection")
- await self.async_websocket_disconnect()
- await self._async_attempt_websocket_connect()
-
- def _on_connect(self):
+ @staticmethod
+ def _on_connect():
"""Define a handler to fire when the websocket is connected."""
_LOGGER.info("Connected to websocket")
- _LOGGER.debug("Websocket watchdog starting")
- if self._websocket_watchdog_listener is not None:
- self._websocket_watchdog_listener()
- self._websocket_watchdog_listener = async_call_later(
- self._hass, DEFAULT_WATCHDOG_SECONDS, self._async_websocket_reconnect
- )
@staticmethod
def _on_disconnect():
@@ -385,13 +353,6 @@ class SimpliSafeWebsocket:
self.last_events[event.system_id] = event
async_dispatcher_send(self._hass, TOPIC_UPDATE.format(event.system_id))
- _LOGGER.debug("Resetting websocket watchdog")
- self._websocket_watchdog_listener()
- self._websocket_watchdog_listener = async_call_later(
- self._hass, DEFAULT_WATCHDOG_SECONDS, self._async_websocket_reconnect
- )
- self._websocket_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY
-
if event.event_type not in WEBSOCKET_EVENTS_TO_TRIGGER_HASS_EVENT:
return
@@ -416,18 +377,11 @@ class SimpliSafeWebsocket:
async def async_websocket_connect(self):
"""Register handlers and connect to the websocket."""
- if self._websocket_reconnect_underway:
- return
-
self._websocket.on_connect(self._on_connect)
self._websocket.on_disconnect(self._on_disconnect)
self._websocket.on_event(self._on_event)
- await self._async_attempt_websocket_connect()
-
- async def async_websocket_disconnect(self):
- """Disconnect from the websocket."""
- await self._websocket.async_disconnect()
+ await self._websocket.async_connect()
class SimpliSafe:
@@ -570,7 +524,10 @@ class SimpliSafeEntity(Entity):
self._online = True
self._simplisafe = simplisafe
self._system = system
- self.websocket_events_to_listen_for = []
+ self.websocket_events_to_listen_for = [
+ EVENT_CONNECTION_LOST,
+ EVENT_CONNECTION_RESTORED,
+ ]
if serial:
self._serial = serial
@@ -697,13 +654,32 @@ class SimpliSafeEntity(Entity):
ATTR_LAST_EVENT_TIMESTAMP: last_websocket_event.timestamp,
}
)
- self.async_update_from_websocket_event(last_websocket_event)
+ self._async_internal_update_from_websocket_event(last_websocket_event)
@callback
def async_update_from_rest_api(self):
"""Update the entity with the provided REST API data."""
pass
+ @callback
+ def _async_internal_update_from_websocket_event(self, event):
+ """Check for connection events and set offline appropriately.
+
+ Should not be called directly.
+ """
+ if event.event_type == EVENT_CONNECTION_LOST:
+ self._online = False
+ elif event.event_type == EVENT_CONNECTION_RESTORED:
+ self._online = True
+
+ # It's uncertain whether SimpliSafe events will still propagate down the
+ # websocket when the base station is offline. Just in case, we guard against
+ # further action until connection is restored:
+ if not self._online:
+ return
+
+ self.async_update_from_websocket_event(event)
+
@callback
def async_update_from_websocket_event(self, event):
"""Update the entity with the provided websocket API data."""
diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py
index c675f9c2748..9166c59bec0 100644
--- a/homeassistant/components/simplisafe/alarm_control_panel.py
+++ b/homeassistant/components/simplisafe/alarm_control_panel.py
@@ -190,11 +190,6 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanel):
@callback
def async_update_from_rest_api(self):
"""Update the entity with the provided REST API data."""
- if self._system.state == SystemStates.error:
- self._online = False
- return
- self._online = True
-
if self._system.version == 3:
self._attrs.update(
{
diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py
index 9c93cd18626..4963f9d2de1 100644
--- a/homeassistant/components/simplisafe/config_flow.py
+++ b/homeassistant/components/simplisafe/config_flow.py
@@ -1,28 +1,16 @@
"""Config flow to configure the SimpliSafe component."""
-from collections import OrderedDict
-
from simplipy import API
from simplipy.errors import SimplipyError
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
-from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client
-from .const import DOMAIN
+from .const import DOMAIN # pylint: disable=unused-import
-@callback
-def configured_instances(hass):
- """Return a set of configured SimpliSafe instances."""
- return set(
- entry.data[CONF_USERNAME] for entry in hass.config_entries.async_entries(DOMAIN)
- )
-
-
-@config_entries.HANDLERS.register(DOMAIN)
-class SimpliSafeFlowHandler(config_entries.ConfigFlow):
+class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a SimpliSafe config flow."""
VERSION = 1
@@ -30,16 +18,19 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow):
def __init__(self):
"""Initialize the config flow."""
- self.data_schema = OrderedDict()
- self.data_schema[vol.Required(CONF_USERNAME)] = str
- self.data_schema[vol.Required(CONF_PASSWORD)] = str
- self.data_schema[vol.Optional(CONF_CODE)] = str
+ self.data_schema = vol.Schema(
+ {
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): str,
+ vol.Optional(CONF_CODE): str,
+ }
+ )
async def _show_form(self, errors=None):
"""Show the form to the user."""
return self.async_show_form(
step_id="user",
- data_schema=vol.Schema(self.data_schema),
+ data_schema=self.data_schema,
errors=errors if errors else {},
)
@@ -49,12 +40,11 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow):
async def async_step_user(self, user_input=None):
"""Handle the start of the config flow."""
-
if not user_input:
return await self._show_form()
- if user_input[CONF_USERNAME] in configured_instances(self.hass):
- return await self._show_form({CONF_USERNAME: "identifier_exists"})
+ await self.async_set_unique_id(user_input[CONF_USERNAME])
+ self._abort_if_unique_id_configured()
username = user_input[CONF_USERNAME]
websession = aiohttp_client.async_get_clientsession(self.hass)
@@ -64,7 +54,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow):
username, user_input[CONF_PASSWORD], websession
)
except SimplipyError:
- return await self._show_form({"base": "invalid_credentials"})
+ return await self._show_form(errors={"base": "invalid_credentials"})
return self.async_create_entry(
title=user_input[CONF_USERNAME],
diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py
index 58448ec4599..fc98d67ccbf 100644
--- a/homeassistant/components/simplisafe/lock.py
+++ b/homeassistant/components/simplisafe/lock.py
@@ -70,11 +70,6 @@ class SimpliSafeLock(SimpliSafeEntity, LockDevice):
@callback
def async_update_from_rest_api(self):
"""Update the entity with the provided REST API data."""
- if self._lock.offline or self._lock.disabled:
- self._online = False
- return
-
- self._online = True
self._attrs.update(
{
ATTR_LOCK_LOW_BATTERY: self._lock.lock_low_battery,
diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json
index 5df0cf400d4..3043bd79104 100644
--- a/homeassistant/components/simplisafe/strings.json
+++ b/homeassistant/components/simplisafe/strings.json
@@ -14,6 +14,9 @@
"error": {
"identifier_exists": "Account already registered",
"invalid_credentials": "Invalid credentials"
+ },
+ "abort": {
+ "already_configured": "This SimpliSafe account is already in use."
}
}
}
diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py
index cbf394edf47..9bd02aec7c4 100644
--- a/homeassistant/components/skybeacon/sensor.py
+++ b/homeassistant/components/skybeacon/sensor.py
@@ -15,6 +15,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
STATE_UNKNOWN,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -82,7 +83,7 @@ class SkybeaconHumid(Entity):
@property
def unit_of_measurement(self):
"""Return the unit the value is expressed in."""
- return "%"
+ return UNIT_PERCENTAGE
@property
def device_state_attributes(self):
diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py
index c61d28bbaac..8f1e034dcac 100644
--- a/homeassistant/components/smappee/sensor.py
+++ b/homeassistant/components/smappee/sensor.py
@@ -2,7 +2,12 @@
from datetime import timedelta
import logging
-from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT
+from homeassistant.const import (
+ ENERGY_KILO_WATT_HOUR,
+ POWER_WATT,
+ UNIT_PERCENTAGE,
+ VOLUME_CUBIC_METERS,
+)
from homeassistant.helpers.entity import Entity
from . import DATA_SMAPPEE
@@ -21,7 +26,13 @@ SENSOR_TYPES = {
],
"current": ["Current", "mdi:gauge", "local", "A", "current"],
"voltage": ["Voltage", "mdi:gauge", "local", "V", "voltage"],
- "active_cosfi": ["Power Factor", "mdi:gauge", "local", "%", "active_cosfi"],
+ "active_cosfi": [
+ "Power Factor",
+ "mdi:gauge",
+ "local",
+ UNIT_PERCENTAGE,
+ "active_cosfi",
+ ],
"alwayson_today": [
"Always On Today",
"mdi:gauge",
@@ -43,8 +54,20 @@ SENSOR_TYPES = {
ENERGY_KILO_WATT_HOUR,
"consumption",
],
- "water_sensor_1": ["Water Sensor 1", "mdi:water", "water", "m3", "value1"],
- "water_sensor_2": ["Water Sensor 2", "mdi:water", "water", "m3", "value2"],
+ "water_sensor_1": [
+ "Water Sensor 1",
+ "mdi:water",
+ "water",
+ VOLUME_CUBIC_METERS,
+ "value1",
+ ],
+ "water_sensor_2": [
+ "Water Sensor 2",
+ "mdi:water",
+ "water",
+ VOLUME_CUBIC_METERS,
+ "value2",
+ ],
"water_sensor_temperature": [
"Water Sensor Temperature",
"mdi:temperature-celsius",
@@ -56,14 +79,14 @@ SENSOR_TYPES = {
"Water Sensor Humidity",
"mdi:water-percent",
"water",
- "%",
+ UNIT_PERCENTAGE,
"humidity",
],
"water_sensor_battery": [
"Water Sensor Battery",
"mdi:battery",
"water",
- "%",
+ UNIT_PERCENTAGE,
"battery",
],
}
diff --git a/homeassistant/components/smarthab/__init__.py b/homeassistant/components/smarthab/__init__.py
index ef2da4e9a1d..778b5171ae4 100644
--- a/homeassistant/components/smarthab/__init__.py
+++ b/homeassistant/components/smarthab/__init__.py
@@ -1,9 +1,4 @@
-"""
-Support for SmartHab device integration.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/smarthab/
-"""
+"""Support for SmartHab device integration."""
import logging
import pysmarthab
diff --git a/homeassistant/components/smarthab/cover.py b/homeassistant/components/smarthab/cover.py
index 9bcb89b7ab4..af55f2de7f9 100644
--- a/homeassistant/components/smarthab/cover.py
+++ b/homeassistant/components/smarthab/cover.py
@@ -1,9 +1,4 @@
-"""
-Support for SmartHab device integration.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/smarthab/
-"""
+"""Support for SmartHab device integration."""
from datetime import timedelta
import logging
diff --git a/homeassistant/components/smarthab/light.py b/homeassistant/components/smarthab/light.py
index bc6eb31fd04..469d89011b8 100644
--- a/homeassistant/components/smarthab/light.py
+++ b/homeassistant/components/smarthab/light.py
@@ -1,9 +1,4 @@
-"""
-Support for SmartHab device integration.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/smarthab/
-"""
+"""Support for SmartHab device integration."""
from datetime import timedelta
import logging
diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py
index 1539fa076e4..a1ea4f98c85 100644
--- a/homeassistant/components/smartthings/__init__.py
+++ b/homeassistant/components/smartthings/__init__.py
@@ -109,8 +109,9 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
entry.data[CONF_OAUTH_CLIENT_SECRET],
entry.data[CONF_REFRESH_TOKEN],
)
- entry.data[CONF_REFRESH_TOKEN] = token.refresh_token
- hass.config_entries.async_update_entry(entry)
+ hass.config_entries.async_update_entry(
+ entry, data={**entry.data, CONF_REFRESH_TOKEN: token.refresh_token}
+ )
# Get devices and their current status
devices = await api.devices(location_ids=[installed_app.location_id])
@@ -304,8 +305,13 @@ class DeviceBroker:
self._entry.data[CONF_OAUTH_CLIENT_ID],
self._entry.data[CONF_OAUTH_CLIENT_SECRET],
)
- self._entry.data[CONF_REFRESH_TOKEN] = self._token.refresh_token
- self._hass.config_entries.async_update_entry(self._entry)
+ self._hass.config_entries.async_update_entry(
+ self._entry,
+ data={
+ **self._entry.data,
+ CONF_REFRESH_TOKEN: self._token.refresh_token,
+ },
+ )
_LOGGER.debug(
"Regenerated refresh token for installed app: %s",
self._installed_app_id,
diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py
index 38e32e90b85..630fbaadd3a 100644
--- a/homeassistant/components/smartthings/sensor.py
+++ b/homeassistant/components/smartthings/sensor.py
@@ -5,6 +5,7 @@ from typing import Optional, Sequence
from pysmartthings import Attribute, Capability
from homeassistant.const import (
+ CONCENTRATION_PARTS_PER_MILLION,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
@@ -15,6 +16,7 @@ from homeassistant.const import (
POWER_WATT,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
)
from . import SmartThingsEntity
@@ -33,8 +35,10 @@ CAPABILITY_TO_SENSORS = {
Map(Attribute.air_quality, "Air Quality", "CAQI", None)
],
Capability.alarm: [Map(Attribute.alarm, "Alarm", None, None)],
- Capability.audio_volume: [Map(Attribute.volume, "Volume", "%", None)],
- Capability.battery: [Map(Attribute.battery, "Battery", "%", DEVICE_CLASS_BATTERY)],
+ Capability.audio_volume: [Map(Attribute.volume, "Volume", UNIT_PERCENTAGE, None)],
+ Capability.battery: [
+ Map(Attribute.battery, "Battery", UNIT_PERCENTAGE, DEVICE_CLASS_BATTERY)
+ ],
Capability.body_mass_index_measurement: [
Map(Attribute.bmi_measurement, "Body Mass Index", "kg/m^2", None)
],
@@ -42,13 +46,23 @@ CAPABILITY_TO_SENSORS = {
Map(Attribute.body_weight_measurement, "Body Weight", MASS_KILOGRAMS, None)
],
Capability.carbon_dioxide_measurement: [
- Map(Attribute.carbon_dioxide, "Carbon Dioxide Measurement", "ppm", None)
+ Map(
+ Attribute.carbon_dioxide,
+ "Carbon Dioxide Measurement",
+ CONCENTRATION_PARTS_PER_MILLION,
+ None,
+ )
],
Capability.carbon_monoxide_detector: [
Map(Attribute.carbon_monoxide, "Carbon Monoxide Detector", None, None)
],
Capability.carbon_monoxide_measurement: [
- Map(Attribute.carbon_monoxide_level, "Carbon Monoxide Measurement", "ppm", None)
+ Map(
+ Attribute.carbon_monoxide_level,
+ "Carbon Monoxide Measurement",
+ CONCENTRATION_PARTS_PER_MILLION,
+ None,
+ )
],
Capability.dishwasher_operating_state: [
Map(Attribute.machine_state, "Dishwasher Machine State", None, None),
@@ -82,18 +96,23 @@ CAPABILITY_TO_SENSORS = {
Map(
Attribute.equivalent_carbon_dioxide_measurement,
"Equivalent Carbon Dioxide Measurement",
- "ppm",
+ CONCENTRATION_PARTS_PER_MILLION,
None,
)
],
Capability.formaldehyde_measurement: [
- Map(Attribute.formaldehyde_level, "Formaldehyde Measurement", "ppm", None)
+ Map(
+ Attribute.formaldehyde_level,
+ "Formaldehyde Measurement",
+ CONCENTRATION_PARTS_PER_MILLION,
+ None,
+ )
],
Capability.illuminance_measurement: [
Map(Attribute.illuminance, "Illuminance", "lux", DEVICE_CLASS_ILLUMINANCE)
],
Capability.infrared_level: [
- Map(Attribute.infrared_level, "Infrared Level", "%", None)
+ Map(Attribute.infrared_level, "Infrared Level", UNIT_PERCENTAGE, None)
],
Capability.media_input_source: [
Map(Attribute.input_source, "Media Input Source", None, None)
@@ -131,7 +150,7 @@ CAPABILITY_TO_SENSORS = {
Map(
Attribute.humidity,
"Relative Humidity Measurement",
- "%",
+ UNIT_PERCENTAGE,
DEVICE_CLASS_HUMIDITY,
)
],
@@ -203,7 +222,12 @@ CAPABILITY_TO_SENSORS = {
Capability.three_axis: [],
Capability.tv_channel: [Map(Attribute.tv_channel, "Tv Channel", None, None)],
Capability.tvoc_measurement: [
- Map(Attribute.tvoc_level, "Tvoc Measurement", "ppm", None)
+ Map(
+ Attribute.tvoc_level,
+ "Tvoc Measurement",
+ CONCENTRATION_PARTS_PER_MILLION,
+ None,
+ )
],
Capability.ultraviolet_index: [
Map(Attribute.ultraviolet_index, "Ultraviolet Index", None, None)
diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py
index f2bfef960cd..402fdbd0715 100644
--- a/homeassistant/components/smartthings/smartapp.py
+++ b/homeassistant/components/smartthings/smartapp.py
@@ -428,8 +428,9 @@ async def smartapp_update(hass: HomeAssistantType, req, resp, app):
None,
)
if entry:
- entry.data[CONF_REFRESH_TOKEN] = req.refresh_token
- hass.config_entries.async_update_entry(entry)
+ hass.config_entries.async_update_entry(
+ entry, data={**entry.data, CONF_REFRESH_TOKEN: req.refresh_token}
+ )
_LOGGER.debug(
"Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id
diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py
index bb6b7623779..e46198b051b 100644
--- a/homeassistant/components/smarty/fan.py
+++ b/homeassistant/components/smarty/fan.py
@@ -76,6 +76,16 @@ class SmartyFan(FanEntity):
"""Return speed of the fan."""
return self._speed
+ def set_speed(self, speed: str) -> None:
+ """Set the speed of the fan."""
+ _LOGGER.debug("Set the fan speed to %s", speed)
+ if speed == SPEED_OFF:
+ self.turn_off()
+ else:
+ self._smarty.set_fan_speed(SPEED_TO_MODE.get(speed))
+ self._speed = speed
+ self._state = True
+
def turn_on(self, speed=None, **kwargs):
"""Turn on the fan."""
_LOGGER.debug("Turning on fan. Speed is %s", speed)
diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py
index 933f8014090..26176e97e46 100644
--- a/homeassistant/components/solarlog/const.py
+++ b/homeassistant/components/solarlog/const.py
@@ -1,7 +1,7 @@
"""Constants for the Solar-Log integration."""
from datetime import timedelta
-from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT
+from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT, UNIT_PERCENTAGE
DOMAIN = "solarlog"
@@ -77,7 +77,7 @@ SENSOR_TYPES = {
POWER_WATT,
"mdi:solar-power",
],
- "capacity": ["CAPACITY", "capacity", "%", "mdi:solar-power"],
+ "capacity": ["CAPACITY", "capacity", UNIT_PERCENTAGE, "mdi:solar-power"],
"efficiency": ["EFFICIENCY", "efficiency", "% W/Wp", "mdi:solar-power"],
"power_available": [
"powerAVAILABLE",
diff --git a/homeassistant/components/soma/.translations/lv.json b/homeassistant/components/soma/.translations/lv.json
new file mode 100644
index 00000000000..a151694b1df
--- /dev/null
+++ b/homeassistant/components/soma/.translations/lv.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "port": "Ports"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/somfy/__init__.py b/homeassistant/components/somfy/__init__.py
index 365c6839300..1b2722882e6 100644
--- a/homeassistant/components/somfy/__init__.py
+++ b/homeassistant/components/somfy/__init__.py
@@ -1,9 +1,4 @@
-"""
-Support for Somfy hubs.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/somfy/
-"""
+"""Support for Somfy hubs."""
import asyncio
from datetime import timedelta
import logging
@@ -32,6 +27,7 @@ DOMAIN = "somfy"
CONF_CLIENT_ID = "client_id"
CONF_CLIENT_SECRET = "client_secret"
+CONF_OPTIMISTIC = "optimisitic"
SOMFY_AUTH_CALLBACK_PATH = "/auth/somfy/callback"
SOMFY_AUTH_START = "/auth/somfy"
@@ -42,6 +38,7 @@ CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_CLIENT_ID): cv.string,
vol.Required(CONF_CLIENT_SECRET): cv.string,
+ vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean,
}
)
},
@@ -58,6 +55,8 @@ async def async_setup(hass, config):
if DOMAIN not in config:
return True
+ hass.data[DOMAIN][CONF_OPTIMISTIC] = config[DOMAIN][CONF_OPTIMISTIC]
+
config_flow.SomfyFlowHandler.async_register_implementation(
hass,
config_entry_oauth2_flow.LocalOAuth2Implementation(
diff --git a/homeassistant/components/somfy/cover.py b/homeassistant/components/somfy/cover.py
index b48e326162d..d0e555ed55c 100644
--- a/homeassistant/components/somfy/cover.py
+++ b/homeassistant/components/somfy/cover.py
@@ -8,7 +8,7 @@ from homeassistant.components.cover import (
CoverDevice,
)
-from . import API, DEVICES, DOMAIN, SomfyEntity
+from . import API, CONF_OPTIMISTIC, DEVICES, DOMAIN, SomfyEntity
async def async_setup_entry(hass, config_entry, async_add_entities):
@@ -25,7 +25,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
devices = hass.data[DOMAIN][DEVICES]
return [
- SomfyCover(cover, hass.data[DOMAIN][API])
+ SomfyCover(
+ cover, hass.data[DOMAIN][API], hass.data[DOMAIN][CONF_OPTIMISTIC]
+ )
for cover in devices
if categories & set(cover.categories)
]
@@ -36,10 +38,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
class SomfyCover(SomfyEntity, CoverDevice):
"""Representation of a Somfy cover device."""
- def __init__(self, device, api):
+ def __init__(self, device, api, optimistic):
"""Initialize the Somfy device."""
super().__init__(device, api)
self.cover = Blind(self.device, self.api)
+ self.optimistic = optimistic
+ self._closed = None
async def async_update(self):
"""Update the device with the latest data."""
@@ -48,10 +52,14 @@ class SomfyCover(SomfyEntity, CoverDevice):
def close_cover(self, **kwargs):
"""Close the cover."""
+ if self.optimistic:
+ self._closed = True
self.cover.close()
def open_cover(self, **kwargs):
"""Open the cover."""
+ if self.optimistic:
+ self._closed = False
self.cover.open()
def stop_cover(self, **kwargs):
@@ -76,6 +84,8 @@ class SomfyCover(SomfyEntity, CoverDevice):
is_closed = None
if self.has_capability("position"):
is_closed = self.cover.is_closed()
+ elif self.optimistic:
+ is_closed = self._closed
return is_closed
@property
diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py
index 37b479a90b1..8828c27e9c7 100644
--- a/homeassistant/components/sonos/media_player.py
+++ b/homeassistant/components/sonos/media_player.py
@@ -93,6 +93,8 @@ ATTR_NIGHT_SOUND = "night_sound"
ATTR_SPEECH_ENHANCE = "speech_enhance"
ATTR_QUEUE_POSITION = "queue_position"
+UNAVAILABLE_VALUES = {"", "NOT_IMPLEMENTED", None}
+
class SonosData:
"""Storage class for platform global data."""
@@ -330,7 +332,7 @@ def soco_coordinator(funct):
def _timespan_secs(timespan):
"""Parse a time-span into number of seconds."""
- if timespan in ("", "NOT_IMPLEMENTED", None):
+ if timespan in UNAVAILABLE_VALUES:
return None
return sum(60 ** x[0] * int(x[1]) for x in enumerate(reversed(timespan.split(":"))))
@@ -366,6 +368,7 @@ class SonosEntity(MediaPlayerDevice):
self._coordinator = None
self._sonos_group = [self]
self._status = None
+ self._uri = None
self._media_duration = None
self._media_position = None
self._media_position_updated_at = None
@@ -426,7 +429,11 @@ class SonosEntity(MediaPlayerDevice):
@soco_coordinator
def state(self):
"""Return the state of the entity."""
- if self._status in ("PAUSED_PLAYBACK", "STOPPED"):
+ if self._status in ("PAUSED_PLAYBACK", "STOPPED",):
+ # Sonos can consider itself "paused" but without having media loaded
+ # (happens if playing Spotify and via Spotify app you pick another device to play on)
+ if self._media_title is None:
+ return STATE_IDLE
return STATE_PAUSED
if self._status in ("PLAYING", "TRANSITIONING"):
return STATE_PLAYING
@@ -510,16 +517,14 @@ class SonosEntity(MediaPlayerDevice):
def _radio_artwork(self, url):
"""Return the private URL with artwork for a radio stream."""
- if url not in ("", "NOT_IMPLEMENTED", None):
- if url.find("tts_proxy") > 0:
- # If the content is a tts don't try to fetch an image from it.
- return None
- url = "http://{host}:{port}/getaa?s=1&u={uri}".format(
- host=self.soco.ip_address,
- port=1400,
- uri=urllib.parse.quote(url, safe=""),
- )
- return url
+ 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."""
@@ -570,6 +575,7 @@ class SonosEntity(MediaPlayerDevice):
return
self._shuffle = self.soco.shuffle
+ self._uri = None
update_position = new_status != self._status
self._status = new_status
@@ -580,6 +586,7 @@ 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
@@ -603,9 +610,9 @@ class SonosEntity(MediaPlayerDevice):
self._media_image_url = None
- self._media_artist = source
+ self._media_artist = None
self._media_album_name = None
- self._media_title = None
+ self._media_title = source
self._source_name = source
@@ -637,7 +644,7 @@ class SonosEntity(MediaPlayerDevice):
# For radio streams we set the radio station name as the title.
current_uri_metadata = media_info["CurrentURIMetaData"]
- if current_uri_metadata not in ("", "NOT_IMPLEMENTED", None):
+ 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)
@@ -647,7 +654,7 @@ class SonosEntity(MediaPlayerDevice):
".//{http://purl.org/dc/elements/1.1/}title"
)
- if md_title not in ("", "NOT_IMPLEMENTED", None):
+ if md_title not in UNAVAILABLE_VALUES:
self._media_title = md_title
if self._media_artist and self._media_title:
@@ -826,6 +833,11 @@ class SonosEntity(MediaPlayerDevice):
"""Shuffling state."""
return self._shuffle
+ @property
+ def media_content_id(self):
+ """Content id of current playing media."""
+ return self._uri
+
@property
def media_content_type(self):
"""Content type of current playing media."""
@@ -859,25 +871,25 @@ class SonosEntity(MediaPlayerDevice):
@soco_coordinator
def media_artist(self):
"""Artist of current playing media, music track only."""
- return self._media_artist
+ return self._media_artist or None
@property
@soco_coordinator
def media_album_name(self):
"""Album name of current playing media, music track only."""
- return self._media_album_name
+ return self._media_album_name or None
@property
@soco_coordinator
def media_title(self):
"""Title of current playing media."""
- return self._media_title
+ return self._media_title or None
@property
@soco_coordinator
def source(self):
"""Name of the current input source."""
- return self._source_name
+ return self._source_name or None
@property
@soco_coordinator
diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py
index 71592e92c17..1d82c38d088 100644
--- a/homeassistant/components/soundtouch/media_player.py
+++ b/homeassistant/components/soundtouch/media_player.py
@@ -22,11 +22,13 @@ from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PORT,
+ EVENT_HOMEASSISTANT_START,
STATE_OFF,
STATE_PAUSED,
STATE_PLAYING,
STATE_UNAVAILABLE,
)
+from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from .const import (
@@ -47,6 +49,8 @@ MAP_STATUS = {
}
DATA_SOUNDTOUCH = "soundtouch"
+ATTR_SOUNDTOUCH_GROUP = "soundtouch_group"
+ATTR_SOUNDTOUCH_ZONE = "soundtouch_zone"
SOUNDTOUCH_PLAY_EVERYWHERE = vol.Schema({vol.Required("master"): cv.entity_id})
@@ -103,7 +107,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
remote_config = {"id": "ha.component.soundtouch", "host": host, "port": port}
bose_soundtouch_entity = SoundTouchDevice(None, remote_config)
hass.data[DATA_SOUNDTOUCH].append(bose_soundtouch_entity)
- add_entities([bose_soundtouch_entity])
+ add_entities([bose_soundtouch_entity], True)
else:
name = config.get(CONF_NAME)
remote_config = {
@@ -113,7 +117,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
}
bose_soundtouch_entity = SoundTouchDevice(name, remote_config)
hass.data[DATA_SOUNDTOUCH].append(bose_soundtouch_entity)
- add_entities([bose_soundtouch_entity])
+ add_entities([bose_soundtouch_entity], True)
def service_handle(service):
"""Handle the applying of a service."""
@@ -191,9 +195,10 @@ class SoundTouchDevice(MediaPlayerDevice):
self._name = self._device.config.name
else:
self._name = name
- self._status = self._device.status()
- self._volume = self._device.volume()
+ self._status = None
+ self._volume = None
self._config = config
+ self._zone = None
@property
def config(self):
@@ -209,6 +214,7 @@ class SoundTouchDevice(MediaPlayerDevice):
"""Retrieve the latest data."""
self._status = self._device.status()
self._volume = self._device.volume()
+ self._zone = self.get_zone_info()
@property
def volume_level(self):
@@ -317,6 +323,18 @@ class SoundTouchDevice(MediaPlayerDevice):
"""Album name of current playing media."""
return self._status.album
+ async def async_added_to_hass(self):
+ """Populate zone info which requires entity_id."""
+
+ @callback
+ def async_update_on_start(event):
+ """Schedule an update when all platform entities have been added."""
+ self.async_schedule_update_ha_state(True)
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, async_update_on_start
+ )
+
def play_media(self, media_type, media_id, **kwargs):
"""Play a piece of media."""
_LOGGER.debug("Starting media with media_id: %s", media_id)
@@ -369,7 +387,13 @@ class SoundTouchDevice(MediaPlayerDevice):
_LOGGER.info(
"Removing slaves from zone with master %s", self._device.config.name
)
- self._device.remove_zone_slave([slave.device for slave in slaves])
+ # SoundTouch API seems to have a bug and won't remove slaves if there are
+ # more than one in the payload. Therefore we have to loop over all slaves
+ # and remove them individually
+ for slave in slaves:
+ # make sure to not try to remove the master (aka current device)
+ if slave.entity_id != self.entity_id:
+ self._device.remove_zone_slave([slave.device])
def add_zone_slave(self, slaves):
"""
@@ -387,3 +411,62 @@ class SoundTouchDevice(MediaPlayerDevice):
"Adding slaves to zone with master %s", self._device.config.name
)
self._device.add_zone_slave([slave.device for slave in slaves])
+
+ @property
+ def device_state_attributes(self):
+ """Return entity specific state attributes."""
+ attributes = {}
+
+ if self._zone and "master" in self._zone:
+ attributes[ATTR_SOUNDTOUCH_ZONE] = self._zone
+ # Compatibility with how other components expose their groups (like SONOS).
+ # First entry is the master, others are slaves
+ group_members = [self._zone["master"]] + self._zone["slaves"]
+ attributes[ATTR_SOUNDTOUCH_GROUP] = group_members
+
+ return attributes
+
+ def get_zone_info(self):
+ """Return the current zone info."""
+ zone_status = self._device.zone_status()
+ if not zone_status:
+ return None
+
+ # Due to a bug in the SoundTouch API itself client devices do NOT return their
+ # siblings as part of the "slaves" list. Only the master has the full list of
+ # 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:
+ 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
+ )
+
+ def _get_instance_by_ip(self, ip_address):
+ """Search and return a SoundTouchDevice instance by it's IP address."""
+ for instance in self.hass.data[DATA_SOUNDTOUCH]:
+ if instance and instance.config["host"] == ip_address:
+ return instance
+ return None
+
+ def _build_zone_info(self, master, zone_slaves):
+ """Build the exposed zone attributes."""
+ slaves = []
+
+ for slave in zone_slaves:
+ slave_instance = self._get_instance_by_ip(slave.device_ip)
+ if slave_instance:
+ slaves.append(slave_instance.entity_id)
+
+ attributes = {
+ "master": master,
+ "is_master": master == self.entity_id,
+ "slaves": slaves,
+ }
+
+ return attributes
diff --git a/homeassistant/components/speedtestdotnet/const.py b/homeassistant/components/speedtestdotnet/const.py
index a08c9421c76..2fed2609fb3 100644
--- a/homeassistant/components/speedtestdotnet/const.py
+++ b/homeassistant/components/speedtestdotnet/const.py
@@ -1,12 +1,12 @@
"""Consts used by Speedtest.net."""
-from homeassistant.const import DATA_RATE_MEGABITS_PER_SECOND
+from homeassistant.const import DATA_RATE_MEGABITS_PER_SECOND, TIME_MILLISECONDS
DOMAIN = "speedtestdotnet"
DATA_UPDATED = f"{DOMAIN}_data_updated"
SENSOR_TYPES = {
- "ping": ["Ping", "ms"],
+ "ping": ["Ping", TIME_MILLISECONDS],
"download": ["Download", DATA_RATE_MEGABITS_PER_SECOND],
"upload": ["Upload", DATA_RATE_MEGABITS_PER_SECOND],
}
diff --git a/homeassistant/components/spotify/.translations/lv.json b/homeassistant/components/spotify/.translations/lv.json
new file mode 100644
index 00000000000..2721c5fdffa
--- /dev/null
+++ b/homeassistant/components/spotify/.translations/lv.json
@@ -0,0 +1,5 @@
+{
+ "config": {
+ "title": "Spotify"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/starline/.translations/lv.json b/homeassistant/components/starline/.translations/lv.json
new file mode 100644
index 00000000000..2b01445d8cb
--- /dev/null
+++ b/homeassistant/components/starline/.translations/lv.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "auth_captcha": {
+ "data": {
+ "captcha_code": "Kods no att\u0113la"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py
index 0c6cd8de683..8e17caad86c 100644
--- a/homeassistant/components/starline/sensor.py
+++ b/homeassistant/components/starline/sensor.py
@@ -1,6 +1,6 @@
"""Reads vehicle status from StarLine API."""
from homeassistant.components.sensor import DEVICE_CLASS_TEMPERATURE
-from homeassistant.const import TEMP_CELSIUS
+from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.icon import icon_for_battery_level, icon_for_signal_level
@@ -13,7 +13,7 @@ SENSOR_TYPES = {
"balance": ["Balance", None, None, "mdi:cash-multiple"],
"ctemp": ["Interior Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, None],
"etemp": ["Engine Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, None],
- "gsm_lvl": ["GSM Signal", None, "%", None],
+ "gsm_lvl": ["GSM Signal", None, UNIT_PERCENTAGE, None],
}
diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py
index 82106c2da57..b6d90b99302 100644
--- a/homeassistant/components/startca/sensor.py
+++ b/homeassistant/components/startca/sensor.py
@@ -13,6 +13,7 @@ from homeassistant.const import (
CONF_MONITORED_VARIABLES,
CONF_NAME,
DATA_GIGABYTES,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
@@ -24,13 +25,11 @@ _LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "Start.ca"
CONF_TOTAL_BANDWIDTH = "total_bandwidth"
-PERCENT = "%"
-
MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1)
REQUEST_TIMEOUT = 5 # seconds
SENSOR_TYPES = {
- "usage": ["Usage Ratio", PERCENT, "mdi:percent"],
+ "usage": ["Usage Ratio", UNIT_PERCENTAGE, "mdi:percent"],
"usage_gb": ["Usage", DATA_GIGABYTES, "mdi:download"],
"limit": ["Data limit", DATA_GIGABYTES, "mdi:download"],
"used_download": ["Used Download", DATA_GIGABYTES, "mdi:download"],
diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py
index d85b6b079ae..0fb08a0fecb 100644
--- a/homeassistant/components/statistics/sensor.py
+++ b/homeassistant/components/statistics/sensor.py
@@ -267,7 +267,7 @@ class StatisticsSensor(Entity):
time_diff = (self.max_age - self.min_age).total_seconds()
if time_diff > 0:
- self.change_rate = self.average_change / time_diff
+ self.change_rate = self.change / time_diff
self.change = round(self.change, self._precision)
self.average_change = round(self.average_change, self._precision)
diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py
index 2ec78c52645..d25ebb7221b 100644
--- a/homeassistant/components/steam_online/sensor.py
+++ b/homeassistant/components/steam_online/sensor.py
@@ -28,6 +28,10 @@ STATE_SNOOZE = "snooze"
STATE_LOOKING_TO_TRADE = "looking_to_trade"
STATE_LOOKING_TO_PLAY = "looking_to_play"
+STEAM_API_URL = "https://steamcdn-a.akamaihd.net/steam/apps/"
+STEAM_HEADER_IMAGE_FILE = "header.jpg"
+STEAM_MAIN_IMAGE_FILE = "capsule_616x353.jpg"
+
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_API_KEY): cv.string,
@@ -73,6 +77,7 @@ class SteamSensor(Entity):
self._account = account
self._profile = None
self._game = None
+ self._game_id = None
self._state = None
self._name = None
self._avatar = None
@@ -104,6 +109,7 @@ class SteamSensor(Entity):
try:
self._profile = self._steamod.user.profile(self._account)
self._game = self._get_current_game()
+ self._game_id = self._profile.current_game[0]
self._state = {
1: STATE_ONLINE,
2: STATE_BUSY,
@@ -119,6 +125,7 @@ class SteamSensor(Entity):
except self._steamod.api.HTTPTimeoutError as error:
_LOGGER.warning(error)
self._game = None
+ self._game_id = None
self._state = None
self._name = None
self._avatar = None
@@ -170,6 +177,11 @@ class SteamSensor(Entity):
attr = {}
if self._game is not None:
attr["game"] = self._game
+ if self._game_id is not None:
+ attr["game_id"] = self._game_id
+ game_url = f"{STEAM_API_URL}{self._game_id}/"
+ attr["game_image_header"] = f"{game_url}{STEAM_HEADER_IMAGE_FILE}"
+ attr["game_image_main"] = f"{game_url}{STEAM_MAIN_IMAGE_FILE}"
if self._last_online is not None:
attr["last_online"] = self._last_online
if self._level is not None:
diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py
index d88f90a83f8..ab80630cb33 100644
--- a/homeassistant/components/stream/__init__.py
+++ b/homeassistant/components/stream/__init__.py
@@ -50,13 +50,12 @@ def request_stream(hass, stream_source, *, fmt="hls", keepalive=False, options=N
options = {}
# For RTSP streams, prefer TCP
- if (
- isinstance(stream_source, str)
- and stream_source[:7] == "rtsp://"
- and not options
- ):
- options["rtsp_flags"] = "prefer_tcp"
- options["stimeout"] = "5000000"
+ if isinstance(stream_source, str) and stream_source[:7] == "rtsp://":
+ options = {
+ "rtsp_flags": "prefer_tcp",
+ "stimeout": "5000000",
+ **options,
+ }
try:
streams = hass.data[DOMAIN][ATTR_STREAMS]
diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py
index 213952bead3..9529a9c0cad 100644
--- a/homeassistant/components/sun/__init__.py
+++ b/homeassistant/components/sun/__init__.py
@@ -77,7 +77,7 @@ async def async_setup(hass, config):
if config.get(CONF_ELEVATION) is not None:
_LOGGER.warning(
"Elevation is now configured in Home Assistant core. "
- "See https://home-assistant.io/docs/configuration/basic/"
+ "See https://www.home-assistant.io/docs/configuration/basic/"
)
Sun(hass)
return True
diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py
index fd60254cd0a..6c9bfb8d16e 100644
--- a/homeassistant/components/supla/__init__.py
+++ b/homeassistant/components/supla/__init__.py
@@ -18,8 +18,10 @@ CONF_SERVERS = "servers"
SUPLA_FUNCTION_HA_CMP_MAP = {
"CONTROLLINGTHEROLLERSHUTTER": "cover",
+ "CONTROLLINGTHEGATE": "cover",
"LIGHTSWITCH": "switch",
}
+SUPLA_FUNCTION_NONE = "NONE"
SUPLA_CHANNELS = "supla_channels"
SUPLA_SERVERS = "supla_servers"
@@ -86,6 +88,14 @@ def discover_devices(hass, hass_config):
for channel in server.get_channels(include=["iodevice"]):
channel_function = channel["function"]["name"]
+ if channel_function == SUPLA_FUNCTION_NONE:
+ _LOGGER.debug(
+ "Ignored function: %s, channel id: %s",
+ channel_function,
+ channel["id"],
+ )
+ continue
+
component_name = SUPLA_FUNCTION_HA_CMP_MAP.get(channel_function)
if component_name is None:
@@ -130,6 +140,16 @@ class SuplaChannel(Entity):
"""Return the name of the device."""
return self.channel_data["caption"]
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ if self.channel_data is None:
+ return False
+ state = self.channel_data.get("state")
+ if state is None:
+ return False
+ return state.get("connected")
+
def action(self, action, **add_pars):
"""
Run server action.
diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py
index 3182aa8c136..659b78cc41a 100644
--- a/homeassistant/components/supla/cover.py
+++ b/homeassistant/components/supla/cover.py
@@ -1,12 +1,19 @@
-"""Support for Supla cover - curtains, rollershutters etc."""
+"""Support for Supla cover - curtains, rollershutters, entry gate etc."""
import logging
from pprint import pformat
-from homeassistant.components.cover import ATTR_POSITION, CoverDevice
+from homeassistant.components.cover import (
+ ATTR_POSITION,
+ DEVICE_CLASS_GARAGE,
+ CoverDevice,
+)
from homeassistant.components.supla import SuplaChannel
_LOGGER = logging.getLogger(__name__)
+SUPLA_SHUTTER = "CONTROLLINGTHEROLLERSHUTTER"
+SUPLA_GATE = "CONTROLLINGTHEGATE"
+
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Supla covers."""
@@ -15,7 +22,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
_LOGGER.debug("Discovery: %s", pformat(discovery_info))
- add_entities([SuplaCover(device) for device in discovery_info])
+ entities = []
+ for device in discovery_info:
+ device_name = device["function"]["name"]
+ if device_name == SUPLA_SHUTTER:
+ entities.append(SuplaCover(device))
+ elif device_name == SUPLA_GATE:
+ entities.append(SuplaGateDoor(device))
+ add_entities(entities)
class SuplaCover(SuplaChannel, CoverDevice):
@@ -51,3 +65,38 @@ class SuplaCover(SuplaChannel, CoverDevice):
def stop_cover(self, **kwargs):
"""Stop the cover."""
self.action("STOP")
+
+
+class SuplaGateDoor(SuplaChannel, CoverDevice):
+ """Representation of a Supla gate door."""
+
+ @property
+ def is_closed(self):
+ """Return if the gate is closed or not."""
+ state = self.channel_data.get("state")
+ if state and "hi" in state:
+ return state.get("hi")
+ return None
+
+ def open_cover(self, **kwargs) -> None:
+ """Open the gate."""
+ if self.is_closed:
+ self.action("OPEN_CLOSE")
+
+ def close_cover(self, **kwargs) -> None:
+ """Close the gate."""
+ if not self.is_closed:
+ self.action("OPEN_CLOSE")
+
+ def stop_cover(self, **kwargs) -> None:
+ """Stop the gate."""
+ self.action("OPEN_CLOSE")
+
+ def toggle(self, **kwargs) -> None:
+ """Toggle the gate."""
+ self.action("OPEN_CLOSE")
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return DEVICE_CLASS_GARAGE
diff --git a/homeassistant/components/supla/switch.py b/homeassistant/components/supla/switch.py
index 725771e21e8..556c1b69a53 100644
--- a/homeassistant/components/supla/switch.py
+++ b/homeassistant/components/supla/switch.py
@@ -1,4 +1,4 @@
-"""Support for Supla cover - curtains, rollershutters etc."""
+"""Support for Supla switch."""
import logging
from pprint import pformat
diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py
index 8dc9cf30e3c..9c1416479cb 100644
--- a/homeassistant/components/surepetcare/sensor.py
+++ b/homeassistant/components/surepetcare/sensor.py
@@ -4,7 +4,13 @@ from typing import Any, Dict, Optional
from surepy import SureLockStateID, SureProductID
-from homeassistant.const import ATTR_VOLTAGE, CONF_ID, CONF_TYPE, DEVICE_CLASS_BATTERY
+from homeassistant.const import (
+ ATTR_VOLTAGE,
+ CONF_ID,
+ CONF_TYPE,
+ DEVICE_CLASS_BATTERY,
+ UNIT_PERCENTAGE,
+)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
@@ -40,10 +46,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
]:
entities.append(SureBattery(entity[CONF_ID], sure_type, spc))
- if sure_type in [
- SureProductID.CAT_FLAP,
- SureProductID.PET_FLAP,
- ]:
+ if sure_type in [SureProductID.CAT_FLAP, SureProductID.PET_FLAP]:
entities.append(Flap(entity[CONF_ID], sure_type, spc))
async_add_entities(entities, True)
@@ -52,9 +55,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
class SurePetcareSensor(Entity):
"""A binary sensor implementation for Sure Petcare Entities."""
- def __init__(
- self, _id: int, sure_type: SureProductID, spc: SurePetcareAPI,
- ):
+ def __init__(self, _id: int, sure_type: SureProductID, spc: SurePetcareAPI):
"""Initialize a Sure Petcare sensor."""
self._id = _id
@@ -128,9 +129,7 @@ class Flap(SurePetcareSensor):
"""Return the state attributes of the device."""
attributes = None
if self._state:
- attributes = {
- "learn_mode": bool(self._state["learn_mode"]),
- }
+ attributes = {"learn_mode": bool(self._state["learn_mode"])}
return attributes
@@ -182,4 +181,4 @@ class SureBattery(SurePetcareSensor):
@property
def unit_of_measurement(self) -> str:
"""Return the unit of measurement."""
- return "%"
+ return UNIT_PERCENTAGE
diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py
index fb5c2969d7e..3884b90c464 100644
--- a/homeassistant/components/switch/__init__.py
+++ b/homeassistant/components/switch/__init__.py
@@ -103,7 +103,7 @@ class SwitchDevice(ToggleEntity):
for prop, attr in PROP_TO_ATTR.items():
value = getattr(self, prop)
- if value:
+ if value is not None:
data[attr] = value
return data
diff --git a/homeassistant/components/switch/manifest.json b/homeassistant/components/switch/manifest.json
index b14c8ca48d5..37cdf77172c 100644
--- a/homeassistant/components/switch/manifest.json
+++ b/homeassistant/components/switch/manifest.json
@@ -3,7 +3,7 @@
"name": "Switch",
"documentation": "https://www.home-assistant.io/integrations/switch",
"requirements": [],
- "dependencies": ["group"],
+ "dependencies": [],
"codeowners": [],
"quality_scale": "internal"
}
diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py
index e981154a81a..7cd99bdb261 100644
--- a/homeassistant/components/syncthru/sensor.py
+++ b/homeassistant/components/syncthru/sensor.py
@@ -6,7 +6,7 @@ from pysyncthru import SyncThru
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_HOST, CONF_NAME, CONF_RESOURCE
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_RESOURCE, UNIT_PERCENTAGE
from homeassistant.helpers import aiohttp_client
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -174,7 +174,7 @@ class SyncThruTonerSensor(SyncThruSensor):
super().__init__(syncthru, name)
self._name = f"{name} Toner {color}"
self._color = color
- self._unit_of_measurement = "%"
+ self._unit_of_measurement = UNIT_PERCENTAGE
self._id_suffix = f"_toner_{color}"
def update(self):
@@ -194,7 +194,7 @@ class SyncThruDrumSensor(SyncThruSensor):
super().__init__(syncthru, name)
self._name = f"{name} Drum {color}"
self._color = color
- self._unit_of_measurement = "%"
+ self._unit_of_measurement = UNIT_PERCENTAGE
self._id_suffix = f"_drum_{color}"
def update(self):
diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py
index 36306efa93e..577a01c5148 100644
--- a/homeassistant/components/synology_srm/device_tracker.py
+++ b/homeassistant/components/synology_srm/device_tracker.py
@@ -1,8 +1,4 @@
-"""Device tracker for Synology SRM routers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.synology_srm/
-"""
+"""Device tracker for Synology SRM routers."""
import logging
import synology_srm
diff --git a/homeassistant/components/synologydsm/sensor.py b/homeassistant/components/synologydsm/sensor.py
index d10ecaa15ed..84921b3b8d3 100644
--- a/homeassistant/components/synologydsm/sensor.py
+++ b/homeassistant/components/synologydsm/sensor.py
@@ -21,6 +21,7 @@ from homeassistant.const import (
DATA_RATE_KILOBYTES_PER_SECOND,
EVENT_HOMEASSISTANT_START,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -37,14 +38,14 @@ DEFAULT_PORT = 5001
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
_UTILISATION_MON_COND = {
- "cpu_other_load": ["CPU Load (Other)", "%", "mdi:chip"],
- "cpu_user_load": ["CPU Load (User)", "%", "mdi:chip"],
- "cpu_system_load": ["CPU Load (System)", "%", "mdi:chip"],
- "cpu_total_load": ["CPU Load (Total)", "%", "mdi:chip"],
- "cpu_1min_load": ["CPU Load (1 min)", "%", "mdi:chip"],
- "cpu_5min_load": ["CPU Load (5 min)", "%", "mdi:chip"],
- "cpu_15min_load": ["CPU Load (15 min)", "%", "mdi:chip"],
- "memory_real_usage": ["Memory Usage (Real)", "%", "mdi:memory"],
+ "cpu_other_load": ["CPU Load (Other)", UNIT_PERCENTAGE, "mdi:chip"],
+ "cpu_user_load": ["CPU Load (User)", UNIT_PERCENTAGE, "mdi:chip"],
+ "cpu_system_load": ["CPU Load (System)", UNIT_PERCENTAGE, "mdi:chip"],
+ "cpu_total_load": ["CPU Load (Total)", UNIT_PERCENTAGE, "mdi:chip"],
+ "cpu_1min_load": ["CPU Load (1 min)", UNIT_PERCENTAGE, "mdi:chip"],
+ "cpu_5min_load": ["CPU Load (5 min)", UNIT_PERCENTAGE, "mdi:chip"],
+ "cpu_15min_load": ["CPU Load (15 min)", UNIT_PERCENTAGE, "mdi:chip"],
+ "memory_real_usage": ["Memory Usage (Real)", UNIT_PERCENTAGE, "mdi:memory"],
"memory_size": ["Memory Size", DATA_MEGABYTES, "mdi:memory"],
"memory_cached": ["Memory Cached", DATA_MEGABYTES, "mdi:memory"],
"memory_available_swap": ["Memory Available (Swap)", DATA_MEGABYTES, "mdi:memory"],
@@ -59,7 +60,7 @@ _STORAGE_VOL_MON_COND = {
"volume_device_type": ["Type", None, "mdi:harddisk"],
"volume_size_total": ["Total Size", None, "mdi:chart-pie"],
"volume_size_used": ["Used Space", None, "mdi:chart-pie"],
- "volume_percentage_used": ["Volume Used", "%", "mdi:chart-pie"],
+ "volume_percentage_used": ["Volume Used", UNIT_PERCENTAGE, "mdi:chart-pie"],
"volume_disk_temp_avg": ["Average Disk Temp", None, "mdi:thermometer"],
"volume_disk_temp_max": ["Maximum Disk Temp", None, "mdi:thermometer"],
}
diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py
index 0c4270eaeef..bf49de5a731 100644
--- a/homeassistant/components/system_log/__init__.py
+++ b/homeassistant/components/system_log/__init__.py
@@ -1,5 +1,5 @@
"""Support for system log."""
-from collections import OrderedDict
+from collections import OrderedDict, deque
import logging
import re
import traceback
@@ -55,28 +55,21 @@ SERVICE_WRITE_SCHEMA = vol.Schema(
def _figure_out_source(record, call_stack, hass):
paths = [HOMEASSISTANT_PATH[0], hass.config.config_dir]
- try:
- # If netdisco is installed check its path too.
- # pylint: disable=import-outside-toplevel
- from netdisco import __path__ as netdisco_path
- paths.append(netdisco_path[0])
- except ImportError:
- pass
# If a stack trace exists, extract file names from the entire call stack.
# The other case is when a regular "log" is made (without an attached
# exception). In that case, just use the file where the log was made from.
if record.exc_info:
- stack = [x[0] for x in traceback.extract_tb(record.exc_info[2])]
+ stack = [(x[0], x[1]) for x in traceback.extract_tb(record.exc_info[2])]
else:
index = -1
for i, frame in enumerate(call_stack):
- if frame == record.pathname:
+ if frame[0] == record.pathname:
index = i
break
if index == -1:
# For some reason we couldn't find pathname in the stack.
- stack = [record.pathname]
+ stack = [(record.pathname, record.lineno)]
else:
stack = call_stack[0 : index + 1]
@@ -86,11 +79,11 @@ def _figure_out_source(record, call_stack, hass):
for pathname in reversed(stack):
# Try to match with a file within Home Assistant
- match = re.match(paths_re, pathname)
+ match = re.match(paths_re, pathname[0])
if match:
- return match.group(1)
+ return [match.group(1), pathname[1]]
# Ok, we don't know what this is
- return record.pathname
+ return (record.pathname, record.lineno)
class LogEntry:
@@ -98,10 +91,10 @@ class LogEntry:
def __init__(self, record, stack, source):
"""Initialize a log entry."""
- self.first_occured = self.timestamp = record.created
+ self.first_occurred = self.timestamp = record.created
self.name = record.name
self.level = record.levelname
- self.message = record.getMessage()
+ self.message = deque([record.getMessage()], maxlen=5)
self.exception = ""
self.root_cause = None
if record.exc_info:
@@ -112,14 +105,20 @@ class LogEntry:
self.root_cause = str(traceback.extract_tb(tb)[-1])
self.source = source
self.count = 1
-
- def hash(self):
- """Calculate a key for DedupStore."""
- return frozenset([self.name, self.message, self.root_cause])
+ self.hash = str([self.name, *self.source, self.root_cause])
def to_dict(self):
"""Convert object into dict to maintain backward compatibility."""
- return vars(self)
+ return {
+ "name": self.name,
+ "message": list(self.message),
+ "level": self.level,
+ "source": self.source,
+ "timestamp": self.timestamp,
+ "exception": self.exception,
+ "count": self.count,
+ "first_occurred": self.first_occurred,
+ }
class DedupStore(OrderedDict):
@@ -132,12 +131,16 @@ class DedupStore(OrderedDict):
def add_entry(self, entry):
"""Add a new entry."""
- key = str(entry.hash())
+ key = entry.hash
if key in self:
# Update stored entry
- self[key].count += 1
- self[key].timestamp = entry.timestamp
+ existing = self[key]
+ existing.count += 1
+ existing.timestamp = entry.timestamp
+
+ if entry.message[0] not in existing.message:
+ existing.message.append(entry.message[0])
self.move_to_end(key)
else:
@@ -172,7 +175,7 @@ class LogErrorHandler(logging.Handler):
if record.levelno >= logging.WARN:
stack = []
if not record.exc_info:
- stack = [f for f, _, _, _ in traceback.extract_stack()]
+ stack = [(f[0], f[1]) for f in traceback.extract_stack()]
entry = LogEntry(
record, stack, _figure_out_source(record, stack, self.hass)
diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py
index 1ea8a409052..bae42c2f50b 100644
--- a/homeassistant/components/systemmonitor/sensor.py
+++ b/homeassistant/components/systemmonitor/sensor.py
@@ -15,6 +15,7 @@ from homeassistant.const import (
DATA_RATE_MEGABYTES_PER_SECOND,
STATE_OFF,
STATE_ON,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -29,7 +30,7 @@ CONF_ARG = "arg"
SENSOR_TYPES = {
"disk_free": ["Disk free", DATA_GIBIBYTES, "mdi:harddisk", None],
"disk_use": ["Disk use", DATA_GIBIBYTES, "mdi:harddisk", None],
- "disk_use_percent": ["Disk use (percent)", "%", "mdi:harddisk", None],
+ "disk_use_percent": ["Disk use (percent)", UNIT_PERCENTAGE, "mdi:harddisk", None],
"ipv4_address": ["IPv4 address", "", "mdi:server-network", None],
"ipv6_address": ["IPv6 address", "", "mdi:server-network", None],
"last_boot": ["Last boot", "", "mdi:clock", "timestamp"],
@@ -38,7 +39,7 @@ SENSOR_TYPES = {
"load_5m": ["Load (5m)", " ", "mdi:memory", None],
"memory_free": ["Memory free", DATA_MEBIBYTES, "mdi:memory", None],
"memory_use": ["Memory use", DATA_MEBIBYTES, "mdi:memory", None],
- "memory_use_percent": ["Memory use (percent)", "%", "mdi:memory", None],
+ "memory_use_percent": ["Memory use (percent)", UNIT_PERCENTAGE, "mdi:memory", None],
"network_in": ["Network in", DATA_MEBIBYTES, "mdi:server-network", None],
"network_out": ["Network out", DATA_MEBIBYTES, "mdi:server-network", None],
"packets_in": ["Packets in", " ", "mdi:server-network", None],
@@ -56,10 +57,10 @@ SENSOR_TYPES = {
None,
],
"process": ["Process", " ", "mdi:memory", None],
- "processor_use": ["Processor use", "%", "mdi:memory", None],
+ "processor_use": ["Processor use", UNIT_PERCENTAGE, "mdi:memory", None],
"swap_free": ["Swap free", DATA_MEBIBYTES, "mdi:harddisk", None],
"swap_use": ["Swap use", DATA_MEBIBYTES, "mdi:harddisk", None],
- "swap_use_percent": ["Swap use (percent)", "%", "mdi:harddisk", None],
+ "swap_use_percent": ["Swap use (percent)", UNIT_PERCENTAGE, "mdi:harddisk", None],
}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py
index f5f32a6ed1a..2cd40bee3fa 100644
--- a/homeassistant/components/tado/sensor.py
+++ b/homeassistant/components/tado/sensor.py
@@ -1,7 +1,7 @@
"""Support for Tado sensors for each zone."""
import logging
-from homeassistant.const import TEMP_CELSIUS
+from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
@@ -134,9 +134,9 @@ class TadoSensor(Entity):
if self.zone_variable == "temperature":
return self.hass.config.units.temperature_unit
if self.zone_variable == "humidity":
- return "%"
+ return UNIT_PERCENTAGE
if self.zone_variable == "heating":
- return "%"
+ return UNIT_PERCENTAGE
if self.zone_variable == "ac":
return ""
diff --git a/homeassistant/components/tahoma/sensor.py b/homeassistant/components/tahoma/sensor.py
index fb8c61607c7..20364a243b3 100644
--- a/homeassistant/components/tahoma/sensor.py
+++ b/homeassistant/components/tahoma/sensor.py
@@ -2,7 +2,7 @@
from datetime import timedelta
import logging
-from homeassistant.const import ATTR_BATTERY_LEVEL, TEMP_CELSIUS
+from homeassistant.const import ATTR_BATTERY_LEVEL, TEMP_CELSIUS, UNIT_PERCENTAGE
from homeassistant.helpers.entity import Entity
from . import DOMAIN as TAHOMA_DOMAIN, TahomaDevice
@@ -51,7 +51,7 @@ class TahomaSensor(TahomaDevice, Entity):
if self.tahoma_device.type == "io:LightIOSystemSensor":
return "lx"
if self.tahoma_device.type == "Humidity Sensor":
- return "%"
+ return UNIT_PERCENTAGE
if self.tahoma_device.type == "rtds:RTDSContactSensor":
return None
if self.tahoma_device.type == "rtds:RTDSMotionSensor":
diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py
index 61a3d7367bf..5847eecc8a8 100644
--- a/homeassistant/components/tank_utility/sensor.py
+++ b/homeassistant/components/tank_utility/sensor.py
@@ -8,7 +8,7 @@ from tank_utility import auth, device as tank_monitor
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD
+from homeassistant.const import CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD, UNIT_PERCENTAGE
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -26,7 +26,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
SENSOR_TYPE = "tank"
SENSOR_ROUNDING_PRECISION = 1
-SENSOR_UNIT_OF_MEASUREMENT = "%"
SENSOR_ATTRS = [
"name",
"address",
@@ -74,7 +73,7 @@ class TankUtilitySensor(Entity):
self._device = device
self._state = None
self._name = f"Tank Utility {self.device}"
- self._unit_of_measurement = SENSOR_UNIT_OF_MEASUREMENT
+ self._unit_of_measurement = UNIT_PERCENTAGE
self._attributes = {}
@property
diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py
new file mode 100755
index 00000000000..fde2f1c57cd
--- /dev/null
+++ b/homeassistant/components/tankerkoenig/__init__.py
@@ -0,0 +1,203 @@
+"""Ask tankerkoenig.de for petrol price information."""
+from datetime import timedelta
+import logging
+
+import pytankerkoenig
+import voluptuous as vol
+
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.const import (
+ CONF_API_KEY,
+ CONF_LATITUDE,
+ CONF_LONGITUDE,
+ CONF_RADIUS,
+ CONF_SCAN_INTERVAL,
+)
+from homeassistant.exceptions import HomeAssistantError
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import async_load_platform
+
+from .const import CONF_FUEL_TYPES, CONF_STATIONS, DOMAIN, FUEL_TYPES
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_RADIUS = 2
+DEFAULT_SCAN_INTERVAL = timedelta(minutes=30)
+
+CONFIG_SCHEMA = vol.Schema(
+ {
+ DOMAIN: vol.Schema(
+ {
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(
+ CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL
+ ): cv.time_period,
+ vol.Optional(CONF_FUEL_TYPES, default=FUEL_TYPES): vol.All(
+ cv.ensure_list, [vol.In(FUEL_TYPES)]
+ ),
+ vol.Inclusive(
+ CONF_LATITUDE,
+ "coordinates",
+ "Latitude and longitude must exist together",
+ ): cv.latitude,
+ vol.Inclusive(
+ CONF_LONGITUDE,
+ "coordinates",
+ "Latitude and longitude must exist together",
+ ): cv.longitude,
+ vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.All(
+ cv.positive_int, vol.Range(min=1)
+ ),
+ vol.Optional(CONF_STATIONS, default=[]): vol.All(
+ cv.ensure_list, [cv.string]
+ ),
+ }
+ )
+ },
+ extra=vol.ALLOW_EXTRA,
+)
+
+
+async def async_setup(hass, config):
+ """Set the tankerkoenig component up."""
+ if DOMAIN not in config:
+ return True
+
+ conf = config[DOMAIN]
+
+ _LOGGER.debug("Setting up integration")
+
+ tankerkoenig = TankerkoenigData(hass, conf)
+
+ latitude = conf.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = conf.get(CONF_LONGITUDE, hass.config.longitude)
+ radius = conf[CONF_RADIUS]
+ additional_stations = conf[CONF_STATIONS]
+
+ setup_ok = await hass.async_add_executor_job(
+ tankerkoenig.setup, latitude, longitude, radius, additional_stations
+ )
+ if not setup_ok:
+ _LOGGER.error("Could not setup integration")
+ return False
+
+ hass.data[DOMAIN] = tankerkoenig
+
+ hass.async_create_task(
+ async_load_platform(
+ hass,
+ SENSOR_DOMAIN,
+ DOMAIN,
+ discovered=tankerkoenig.stations,
+ hass_config=conf,
+ )
+ )
+
+ return True
+
+
+class TankerkoenigData:
+ """Get the latest data from the API."""
+
+ def __init__(self, hass, conf):
+ """Initialize the data object."""
+ self._api_key = conf[CONF_API_KEY]
+ self.stations = {}
+ self.fuel_types = conf[CONF_FUEL_TYPES]
+ self.update_interval = conf[CONF_SCAN_INTERVAL]
+ self._hass = hass
+
+ def setup(self, latitude, longitude, radius, additional_stations):
+ """Set up the tankerkoenig API.
+
+ Read the initial data from the server, to initialize the list of fuel stations to monitor.
+ """
+ _LOGGER.debug("Fetching data for (%s, %s) rad: %s", latitude, longitude, radius)
+ try:
+ data = pytankerkoenig.getNearbyStations(
+ self._api_key, latitude, longitude, radius, "all", "dist"
+ )
+ except pytankerkoenig.customException as err:
+ data = {"ok": False, "message": err, "exception": True}
+ _LOGGER.debug("Received data: %s", data)
+ if not data["ok"]:
+ _LOGGER.error(
+ "Setup for sensors was unsuccessful. Error occurred while fetching data from tankerkoenig.de: %s",
+ data["message"],
+ )
+ return False
+
+ # Add stations found via location + radius
+ nearby_stations = data["stations"]
+ if not nearby_stations:
+ if not additional_stations:
+ _LOGGER.error(
+ "Could not find any station in range."
+ "Try with a bigger radius or manually specify stations in additional_stations"
+ )
+ return False
+ _LOGGER.warning(
+ "Could not find any station in range. Will only use manually specified stations"
+ )
+ else:
+ for station in data["stations"]:
+ self.add_station(station)
+
+ # Add manually specified additional stations
+ for station_id in additional_stations:
+ try:
+ additional_station_data = pytankerkoenig.getStationData(
+ self._api_key, station_id
+ )
+ except pytankerkoenig.customException as err:
+ additional_station_data = {
+ "ok": False,
+ "message": err,
+ "exception": True,
+ }
+
+ if not additional_station_data["ok"]:
+ _LOGGER.error(
+ "Error when adding station %s:\n %s",
+ station_id,
+ additional_station_data["message"],
+ )
+ return False
+ self.add_station(additional_station_data["station"])
+ return True
+
+ async def fetch_data(self):
+ """Get the latest data from tankerkoenig.de."""
+ _LOGGER.debug("Fetching new data from tankerkoenig.de")
+ station_ids = list(self.stations)
+ data = await self._hass.async_add_executor_job(
+ pytankerkoenig.getPriceList, self._api_key, station_ids
+ )
+
+ if data["ok"]:
+ _LOGGER.debug("Received data: %s", data)
+ if "prices" not in data:
+ _LOGGER.error("Did not receive price information from tankerkoenig.de")
+ raise TankerkoenigError("No prices in data")
+ else:
+ _LOGGER.error(
+ "Error fetching data from tankerkoenig.de: %s", data["message"]
+ )
+ raise TankerkoenigError(data["message"])
+ return data["prices"]
+
+ def add_station(self, station: dict):
+ """Add fuel station to the entity list."""
+ station_id = station["id"]
+ if station_id in self.stations:
+ _LOGGER.warning(
+ "Sensor for station with id %s was already created", station_id
+ )
+ return
+
+ self.stations[station_id] = station
+ _LOGGER.debug("add_station called for station: %s", station)
+
+
+class TankerkoenigError(HomeAssistantError):
+ """An error occurred while contacting tankerkoenig.de."""
diff --git a/homeassistant/components/tankerkoenig/const.py b/homeassistant/components/tankerkoenig/const.py
new file mode 100644
index 00000000000..04e6e08ba37
--- /dev/null
+++ b/homeassistant/components/tankerkoenig/const.py
@@ -0,0 +1,9 @@
+"""Constants for the tankerkoenig integration."""
+
+DOMAIN = "tankerkoenig"
+NAME = "tankerkoenig"
+
+CONF_FUEL_TYPES = "fuel_types"
+CONF_STATIONS = "stations"
+
+FUEL_TYPES = ["e5", "e10", "diesel"]
diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json
new file mode 100755
index 00000000000..1b22e62d5ef
--- /dev/null
+++ b/homeassistant/components/tankerkoenig/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "tankerkoenig",
+ "name": "Tankerkoenig",
+ "documentation": "https://www.home-assistant.io/integrations/tankerkoenig",
+ "requirements": ["pytankerkoenig==0.0.6"],
+ "dependencies": [],
+ "codeowners": [
+ "@guillempages"
+ ]
+}
diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py
new file mode 100755
index 00000000000..2fb184848ea
--- /dev/null
+++ b/homeassistant/components/tankerkoenig/sensor.py
@@ -0,0 +1,150 @@
+"""Tankerkoenig sensor integration."""
+
+import logging
+
+from homeassistant.const import ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN, NAME
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_BRAND = "brand"
+ATTR_CITY = "city"
+ATTR_FUEL_TYPE = "fuel_type"
+ATTR_HOUSE_NUMBER = "house_number"
+ATTR_IS_OPEN = "is_open"
+ATTR_POSTCODE = "postcode"
+ATTR_STATION_NAME = "station_name"
+ATTR_STREET = "street"
+ATTRIBUTION = "Data provided by https://creativecommons.tankerkoenig.de"
+
+ICON = "mdi:gas-station"
+
+
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+ """Set up the tankerkoenig sensors."""
+
+ if discovery_info is None:
+ return
+
+ tankerkoenig = hass.data[DOMAIN]
+
+ async def async_update_data():
+ """Fetch data from API endpoint."""
+ try:
+ return await tankerkoenig.fetch_data()
+ except LookupError:
+ raise UpdateFailed("Failed to fetch data")
+
+ coordinator = DataUpdateCoordinator(
+ hass,
+ _LOGGER,
+ name=NAME,
+ update_method=async_update_data,
+ update_interval=tankerkoenig.update_interval,
+ )
+
+ # Fetch initial data so we have data when entities subscribe
+ await coordinator.async_refresh()
+
+ stations = discovery_info.values()
+ entities = []
+ for station in stations:
+ for fuel in tankerkoenig.fuel_types:
+ if fuel not in station:
+ _LOGGER.warning(
+ "Station %s does not offer %s fuel", station["id"], fuel
+ )
+ continue
+ sensor = FuelPriceSensor(
+ fuel, station, coordinator, f"{NAME}_{station['name']}_{fuel}"
+ )
+ entities.append(sensor)
+ _LOGGER.debug("Added sensors %s", entities)
+
+ async_add_entities(entities)
+
+
+class FuelPriceSensor(Entity):
+ """Contains prices for fuel in a given station."""
+
+ def __init__(self, fuel_type, station, coordinator, name):
+ """Initialize the sensor."""
+ self._station = station
+ self._station_id = station["id"]
+ self._fuel_type = fuel_type
+ self._coordinator = coordinator
+ self._name = name
+ self._latitude = station["lat"]
+ self._longitude = station["lng"]
+ self._city = station["place"]
+ self._house_number = station["houseNumber"]
+ self._postcode = station["postCode"]
+ self._street = station["street"]
+ self._price = station[fuel_type]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return ICON
+
+ @property
+ def unit_of_measurement(self):
+ """Return unit of measurement."""
+ return "€"
+
+ @property
+ def should_poll(self):
+ """No need to poll. Coordinator notifies of updates."""
+ return False
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ # key Fuel_type is not available when the fuel station is closed, use "get" instead of "[]" to avoid exceptions
+ return self._coordinator.data[self._station_id].get(self._fuel_type)
+
+ @property
+ def device_state_attributes(self):
+ """Return the attributes of the device."""
+ data = self._coordinator.data[self._station_id]
+
+ attrs = {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_BRAND: self._station["brand"],
+ ATTR_FUEL_TYPE: self._fuel_type,
+ ATTR_STATION_NAME: self._station["name"],
+ ATTR_STREET: self._street,
+ ATTR_HOUSE_NUMBER: self._house_number,
+ ATTR_POSTCODE: self._postcode,
+ ATTR_CITY: self._city,
+ ATTR_LATITUDE: self._latitude,
+ ATTR_LONGITUDE: self._longitude,
+ }
+ if data is not None and "status" in data:
+ attrs[ATTR_IS_OPEN] = data["status"] == "open"
+ return attrs
+
+ @property
+ def available(self):
+ """Return if entity is available."""
+ return self._coordinator.last_update_success
+
+ async def async_added_to_hass(self):
+ """When entity is added to hass."""
+ self._coordinator.async_add_listener(self.async_write_ha_state)
+
+ async def async_will_remove_from_hass(self):
+ """When entity will be removed from hass."""
+ self._coordinator.async_remove_listener(self.async_write_ha_state)
+
+ async def async_update(self):
+ """Update the entity."""
+ await self._coordinator.async_request_refresh()
diff --git a/homeassistant/components/teksavvy/sensor.py b/homeassistant/components/teksavvy/sensor.py
index f340f4a3971..0d2d290fed9 100644
--- a/homeassistant/components/teksavvy/sensor.py
+++ b/homeassistant/components/teksavvy/sensor.py
@@ -11,6 +11,7 @@ from homeassistant.const import (
CONF_MONITORED_VARIABLES,
CONF_NAME,
DATA_GIGABYTES,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
@@ -22,13 +23,11 @@ _LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "TekSavvy"
CONF_TOTAL_BANDWIDTH = "total_bandwidth"
-PERCENT = "%"
-
MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1)
REQUEST_TIMEOUT = 5 # seconds
SENSOR_TYPES = {
- "usage": ["Usage Ratio", PERCENT, "mdi:percent"],
+ "usage": ["Usage Ratio", UNIT_PERCENTAGE, "mdi:percent"],
"usage_gb": ["Usage", DATA_GIGABYTES, "mdi:download"],
"limit": ["Data limit", DATA_GIGABYTES, "mdi:download"],
"onpeak_download": ["On Peak Download", DATA_GIGABYTES, "mdi:download"],
diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py
index 7d9f940f391..11411e1d6ea 100644
--- a/homeassistant/components/tellduslive/sensor.py
+++ b/homeassistant/components/tellduslive/sensor.py
@@ -7,7 +7,10 @@ from homeassistant.const import (
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_TEMPERATURE,
POWER_WATT,
+ SPEED_METERS_PER_SECOND,
TEMP_CELSIUS,
+ TIME_HOURS,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -35,12 +38,12 @@ SENSOR_TYPES = {
None,
DEVICE_CLASS_TEMPERATURE,
],
- SENSOR_TYPE_HUMIDITY: ["Humidity", "%", None, DEVICE_CLASS_HUMIDITY],
- SENSOR_TYPE_RAINRATE: ["Rain rate", "mm/h", "mdi:water", None],
+ SENSOR_TYPE_HUMIDITY: ["Humidity", UNIT_PERCENTAGE, None, DEVICE_CLASS_HUMIDITY],
+ SENSOR_TYPE_RAINRATE: ["Rain rate", f"mm/{TIME_HOURS}", "mdi:water", None],
SENSOR_TYPE_RAINTOTAL: ["Rain total", "mm", "mdi:water", None],
SENSOR_TYPE_WINDDIRECTION: ["Wind direction", "", "", None],
- SENSOR_TYPE_WINDAVERAGE: ["Wind average", "m/s", "", None],
- SENSOR_TYPE_WINDGUST: ["Wind gust", "m/s", "", None],
+ SENSOR_TYPE_WINDAVERAGE: ["Wind average", SPEED_METERS_PER_SECOND, "", None],
+ SENSOR_TYPE_WINDGUST: ["Wind gust", SPEED_METERS_PER_SECOND, "", None],
SENSOR_TYPE_UV: ["UV", "UV", "", None],
SENSOR_TYPE_WATT: ["Power", POWER_WATT, "", None],
SENSOR_TYPE_LUMINANCE: ["Luminance", "lx", None, DEVICE_CLASS_ILLUMINANCE],
diff --git a/homeassistant/components/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py
index 1a55e67ac43..4a3ff75b864 100644
--- a/homeassistant/components/tellstick/sensor.py
+++ b/homeassistant/components/tellstick/sensor.py
@@ -7,7 +7,13 @@ import tellcore.constants as tellcore_constants
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_ID, CONF_NAME, CONF_PROTOCOL, TEMP_CELSIUS
+from homeassistant.const import (
+ CONF_ID,
+ CONF_NAME,
+ CONF_PROTOCOL,
+ TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
+)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -55,7 +61,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
tellcore_constants.TELLSTICK_TEMPERATURE: DatatypeDescription(
"temperature", config.get(CONF_TEMPERATURE_SCALE)
),
- tellcore_constants.TELLSTICK_HUMIDITY: DatatypeDescription("humidity", "%"),
+ tellcore_constants.TELLSTICK_HUMIDITY: DatatypeDescription(
+ "humidity", UNIT_PERCENTAGE
+ ),
tellcore_constants.TELLSTICK_RAINRATE: DatatypeDescription("rain rate", ""),
tellcore_constants.TELLSTICK_RAINTOTAL: DatatypeDescription("rain total", ""),
tellcore_constants.TELLSTICK_WINDDIRECTION: DatatypeDescription(
diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py
index 7de43ea0702..8991ce4c65b 100644
--- a/homeassistant/components/template/binary_sensor.py
+++ b/homeassistant/components/template/binary_sensor.py
@@ -103,12 +103,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
attribute_templates,
)
)
- if not sensors:
- _LOGGER.error("No sensors added")
- return False
async_add_entities(sensors)
- return True
class BinarySensorTemplate(BinarySensorDevice):
diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py
index 870e4035c2f..14fc6996378 100644
--- a/homeassistant/components/template/cover.py
+++ b/homeassistant/components/template/cover.py
@@ -65,30 +65,33 @@ TILT_FEATURES = (
| SUPPORT_SET_TILT_POSITION
)
-COVER_SCHEMA = vol.Schema(
- {
- vol.Inclusive(OPEN_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA,
- vol.Inclusive(CLOSE_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA,
- vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA,
- vol.Exclusive(
- CONF_POSITION_TEMPLATE, CONF_VALUE_OR_POSITION_TEMPLATE
- ): cv.template,
- vol.Exclusive(
- CONF_VALUE_TEMPLATE, CONF_VALUE_OR_POSITION_TEMPLATE
- ): cv.template,
- vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
- vol.Optional(CONF_POSITION_TEMPLATE): cv.template,
- vol.Optional(CONF_TILT_TEMPLATE): cv.template,
- vol.Optional(CONF_ICON_TEMPLATE): cv.template,
- vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
- vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
- vol.Optional(CONF_OPTIMISTIC): cv.boolean,
- vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean,
- vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(CONF_FRIENDLY_NAME): cv.string,
- vol.Optional(CONF_ENTITY_ID): cv.entity_ids,
- }
+COVER_SCHEMA = vol.All(
+ vol.Schema(
+ {
+ vol.Inclusive(OPEN_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA,
+ vol.Inclusive(CLOSE_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA,
+ vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Exclusive(
+ CONF_POSITION_TEMPLATE, CONF_VALUE_OR_POSITION_TEMPLATE
+ ): cv.template,
+ vol.Exclusive(
+ CONF_VALUE_TEMPLATE, CONF_VALUE_OR_POSITION_TEMPLATE
+ ): cv.template,
+ vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
+ vol.Optional(CONF_POSITION_TEMPLATE): cv.template,
+ vol.Optional(CONF_TILT_TEMPLATE): cv.template,
+ vol.Optional(CONF_ICON_TEMPLATE): cv.template,
+ vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_OPTIMISTIC): cv.boolean,
+ vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean,
+ vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_FRIENDLY_NAME): cv.string,
+ vol.Optional(CONF_ENTITY_ID): cv.entity_ids,
+ }
+ ),
+ cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION),
)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@@ -118,12 +121,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
optimistic = device_config.get(CONF_OPTIMISTIC)
tilt_optimistic = device_config.get(CONF_TILT_OPTIMISTIC)
- if position_action is None and open_action is None:
- _LOGGER.error(
- "Must specify at least one of %s" or "%s", OPEN_ACTION, POSITION_ACTION
- )
- continue
-
templates = {
CONF_VALUE_TEMPLATE: state_template,
CONF_POSITION_TEMPLATE: position_template,
@@ -160,12 +157,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
entity_ids,
)
)
- if not covers:
- _LOGGER.error("No covers added")
- return False
async_add_entities(covers)
- return True
class CoverTemplate(CoverDevice):
diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py
index a6855a1654b..7948782479b 100644
--- a/homeassistant/components/template/light.py
+++ b/homeassistant/components/template/light.py
@@ -131,12 +131,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
)
)
- if not lights:
- _LOGGER.error("No lights added")
- return False
-
async_add_entities(lights)
- return True
class LightTemplate(Light):
diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py
index c2d8e8158c1..f96ed5479b9 100644
--- a/homeassistant/components/template/switch.py
+++ b/homeassistant/components/template/switch.py
@@ -93,12 +93,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
)
)
- if not switches:
- _LOGGER.error("No switches added")
- return False
-
async_add_entities(switches)
- return True
class SwitchTemplate(SwitchDevice):
diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py
index dee2a021829..26cf0fed5e8 100644
--- a/homeassistant/components/tensorflow/image_processing.py
+++ b/homeassistant/components/tensorflow/image_processing.py
@@ -4,7 +4,7 @@ import logging
import os
import sys
-from PIL import Image, ImageDraw
+from PIL import Image, ImageDraw, UnidentifiedImageError
import numpy as np
import voluptuous as vol
@@ -287,7 +287,11 @@ class TensorFlowImageProcessor(ImageProcessingEntity):
inp = img[:, :, [2, 1, 0]] # BGR->RGB
inp_expanded = inp.reshape(1, inp.shape[0], inp.shape[1], 3)
except ImportError:
- img = Image.open(io.BytesIO(bytearray(image))).convert("RGB")
+ try:
+ img = Image.open(io.BytesIO(bytearray(image))).convert("RGB")
+ except UnidentifiedImageError:
+ _LOGGER.warning("Unable to process image, bad data")
+ return
img.thumbnail((460, 460), Image.ANTIALIAS)
img_width, img_height = img.size
inp = (
diff --git a/homeassistant/components/tesla/.translations/lv.json b/homeassistant/components/tesla/.translations/lv.json
new file mode 100644
index 00000000000..eab98211e14
--- /dev/null
+++ b/homeassistant/components/tesla/.translations/lv.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "Parole",
+ "username": "E-pasta adrese"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tesla/device_tracker.py b/homeassistant/components/tesla/device_tracker.py
index f39d8055b12..08e5d58ba6e 100644
--- a/homeassistant/components/tesla/device_tracker.py
+++ b/homeassistant/components/tesla/device_tracker.py
@@ -68,8 +68,3 @@ class TeslaDeviceEntity(TeslaDevice, TrackerEntity):
def source_type(self):
"""Return the source type, eg gps or router, of the device."""
return SOURCE_TYPE_GPS
-
- @property
- def force_update(self):
- """All updates do not need to be written to the state machine."""
- return False
diff --git a/homeassistant/components/tesla/manifest.json b/homeassistant/components/tesla/manifest.json
index f536cdf96b4..950a860b308 100644
--- a/homeassistant/components/tesla/manifest.json
+++ b/homeassistant/components/tesla/manifest.json
@@ -3,7 +3,12 @@
"name": "Tesla",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tesla",
- "requirements": ["teslajsonpy==0.3.0"],
+ "requirements": [
+ "teslajsonpy==0.5.1"
+ ],
"dependencies": [],
- "codeowners": ["@zabuldon", "@alandtse"]
-}
+ "codeowners": [
+ "@zabuldon",
+ "@alandtse"
+ ]
+}
\ No newline at end of file
diff --git a/homeassistant/components/tesla/sensor.py b/homeassistant/components/tesla/sensor.py
index 9b06828693f..62bdebbb1f3 100644
--- a/homeassistant/components/tesla/sensor.py
+++ b/homeassistant/components/tesla/sensor.py
@@ -95,6 +95,7 @@ class TeslaSensor(TeslaDevice, Entity):
self._attributes = {
"time_left": self.tesla_device.time_left,
"added_range": self.tesla_device.added_range,
+ "charge_energy_added": self.tesla_device.charge_energy_added,
"charge_current_request": self.tesla_device.charge_current_request,
"charger_actual_current": self.tesla_device.charger_actual_current,
"charger_voltage": self.tesla_device.charger_voltage,
diff --git a/homeassistant/components/thermoworks_smoke/sensor.py b/homeassistant/components/thermoworks_smoke/sensor.py
index d5af021108a..83a2fd12d24 100644
--- a/homeassistant/components/thermoworks_smoke/sensor.py
+++ b/homeassistant/components/thermoworks_smoke/sensor.py
@@ -2,9 +2,6 @@
Support for getting the state of a Thermoworks Smoke Thermometer.
Requires Smoke Gateway Wifi with an internet connection.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/sensor.thermoworks_smoke/
"""
import logging
diff --git a/homeassistant/components/thinkingcleaner/sensor.py b/homeassistant/components/thinkingcleaner/sensor.py
index 7a45be7eb61..724ed54e4da 100644
--- a/homeassistant/components/thinkingcleaner/sensor.py
+++ b/homeassistant/components/thinkingcleaner/sensor.py
@@ -2,9 +2,13 @@
from datetime import timedelta
import logging
-from pythinkingcleaner import Discovery
+from pythinkingcleaner import Discovery, ThinkingCleaner
+import voluptuous as vol
from homeassistant import util
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_HOST, UNIT_PERCENTAGE
+import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
_LOGGER = logging.getLogger(__name__)
@@ -13,7 +17,7 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100)
SENSOR_TYPES = {
- "battery": ["Battery", "%", "mdi:battery"],
+ "battery": ["Battery", UNIT_PERCENTAGE, "mdi:battery"],
"state": ["State", None, None],
"capacity": ["Capacity", None, None],
}
@@ -45,12 +49,18 @@ STATES = {
"st_unknown": "Unknown state",
}
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Optional(CONF_HOST): cv.string})
+
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the ThinkingCleaner platform."""
- discovery = Discovery()
- devices = discovery.discover()
+ host = config.get(CONF_HOST)
+ if host:
+ devices = [ThinkingCleaner(host, "unknown")]
+ else:
+ discovery = Discovery()
+ devices = discovery.discover()
@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
def update_devices():
diff --git a/homeassistant/components/thinkingcleaner/switch.py b/homeassistant/components/thinkingcleaner/switch.py
index 88d87e4e5fe..172951ed1ef 100644
--- a/homeassistant/components/thinkingcleaner/switch.py
+++ b/homeassistant/components/thinkingcleaner/switch.py
@@ -3,10 +3,13 @@ from datetime import timedelta
import logging
import time
-from pythinkingcleaner import Discovery
+from pythinkingcleaner import Discovery, ThinkingCleaner
+import voluptuous as vol
from homeassistant import util
-from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.components.switch import PLATFORM_SCHEMA
+from homeassistant.const import CONF_HOST, STATE_OFF, STATE_ON
+import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import ToggleEntity
_LOGGER = logging.getLogger(__name__)
@@ -23,12 +26,17 @@ SWITCH_TYPES = {
"find": ["Find", None, None],
}
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Optional(CONF_HOST): cv.string})
+
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the ThinkingCleaner platform."""
-
- discovery = Discovery()
- devices = discovery.discover()
+ host = config.get(CONF_HOST)
+ if host:
+ devices = [ThinkingCleaner(host, "unknown")]
+ else:
+ discovery = Discovery()
+ devices = discovery.discover()
@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
def update_devices():
diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py
index df56989714f..53c02a1461a 100644
--- a/homeassistant/components/tibber/__init__.py
+++ b/homeassistant/components/tibber/__init__.py
@@ -10,10 +10,13 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, EVENT_HOMEASSISTAN
from homeassistant.helpers import discovery
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import async_call_later
from homeassistant.util import dt as dt_util
DOMAIN = "tibber"
+FIRST_RETRY_TIME = 60
+
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})},
extra=vol.ALLOW_EXTRA,
@@ -22,7 +25,7 @@ CONFIG_SCHEMA = vol.Schema(
_LOGGER = logging.getLogger(__name__)
-async def async_setup(hass, config):
+async def async_setup(hass, config, retry_delay=FIRST_RETRY_TIME):
"""Set up the Tibber component."""
conf = config.get(DOMAIN)
@@ -40,9 +43,16 @@ async def async_setup(hass, config):
try:
await tibber_connection.update_info()
- except asyncio.TimeoutError as err:
- _LOGGER.error("Timeout connecting to Tibber: %s ", err)
- return False
+ except asyncio.TimeoutError:
+ _LOGGER.warning("Timeout connecting to Tibber. Will retry in %ss", retry_delay)
+
+ async def retry_setup(now):
+ """Retry setup if a timeout happens on Tibber API."""
+ await async_setup(hass, config, retry_delay=min(2 * retry_delay, 900))
+
+ async_call_later(hass, retry_delay, retry_setup)
+
+ return True
except aiohttp.ClientError as err:
_LOGGER.error("Error connecting to Tibber: %s ", err)
return False
diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json
index 9f8579e3e18..78b358d70ec 100644
--- a/homeassistant/components/tibber/manifest.json
+++ b/homeassistant/components/tibber/manifest.json
@@ -2,7 +2,7 @@
"domain": "tibber",
"name": "Tibber",
"documentation": "https://www.home-assistant.io/integrations/tibber",
- "requirements": ["pyTibber==0.12.2"],
+ "requirements": ["pyTibber==0.13.3"],
"dependencies": [],
"codeowners": ["@danielhiversen"],
"quality_scale": "silver"
diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py
index abf3a6ab0f7..5172322a63d 100644
--- a/homeassistant/components/timer/__init__.py
+++ b/homeassistant/components/timer/__init__.py
@@ -239,7 +239,8 @@ class Timer(RestoreEntity):
state = await self.async_get_last_state()
self._state = state and state.state == state
- async def async_start(self, duration):
+ @callback
+ def async_start(self, duration):
"""Start a timer."""
if self._listener:
self._listener()
@@ -267,11 +268,12 @@ class Timer(RestoreEntity):
self.hass.bus.async_fire(event, {"entity_id": self.entity_id})
self._listener = async_track_point_in_utc_time(
- self.hass, self.async_finished, self._end
+ self.hass, self._async_finished, self._end
)
self.async_write_ha_state()
- async def async_pause(self):
+ @callback
+ def async_pause(self):
"""Pause a timer."""
if self._listener is None:
return
@@ -284,7 +286,8 @@ class Timer(RestoreEntity):
self.hass.bus.async_fire(EVENT_TIMER_PAUSED, {"entity_id": self.entity_id})
self.async_write_ha_state()
- async def async_cancel(self):
+ @callback
+ def async_cancel(self):
"""Cancel a timer."""
if self._listener:
self._listener()
@@ -295,7 +298,8 @@ class Timer(RestoreEntity):
self.hass.bus.async_fire(EVENT_TIMER_CANCELLED, {"entity_id": self.entity_id})
self.async_write_ha_state()
- async def async_finish(self):
+ @callback
+ def async_finish(self):
"""Reset and updates the states, fire finished event."""
if self._state != STATUS_ACTIVE:
return
@@ -306,7 +310,8 @@ class Timer(RestoreEntity):
self.hass.bus.async_fire(EVENT_TIMER_FINISHED, {"entity_id": self.entity_id})
self.async_write_ha_state()
- async def async_finished(self, time):
+ @callback
+ def _async_finished(self, time):
"""Reset and updates the states, fire finished event."""
if self._state != STATUS_ACTIVE:
return
diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py
index 6d8bdc7eac7..8eb0673aa73 100644
--- a/homeassistant/components/tmb/sensor.py
+++ b/homeassistant/components/tmb/sensor.py
@@ -7,7 +7,7 @@ from tmb import IBus
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle
@@ -72,7 +72,7 @@ class TMBSensor(Entity):
self._stop = stop
self._line = line.upper()
self._name = name
- self._unit = "minutes"
+ self._unit = TIME_MINUTES
self._state = None
@property
diff --git a/homeassistant/components/toon/.translations/en.json b/homeassistant/components/toon/.translations/en.json
index cea3146a3a5..7d7d6c73e16 100644
--- a/homeassistant/components/toon/.translations/en.json
+++ b/homeassistant/components/toon/.translations/en.json
@@ -5,7 +5,7 @@
"client_secret": "The client secret from the configuration is invalid.",
"no_agreements": "This account has no Toon displays.",
"no_app": "You need to configure Toon before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Unexpected error occured, while authenticating."
+ "unknown_auth_fail": "Unexpected error occurred, while authenticating."
},
"error": {
"credentials": "The provided credentials are invalid.",
diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py
index a22835ec215..239359c1fdf 100644
--- a/homeassistant/components/toon/const.py
+++ b/homeassistant/components/toon/const.py
@@ -18,6 +18,5 @@ DEFAULT_MAX_TEMP = 30.0
DEFAULT_MIN_TEMP = 6.0
CURRENCY_EUR = "EUR"
-RATIO_PERCENT = "%"
VOLUME_CM3 = "CM3"
VOLUME_M3 = "M3"
diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py
index 79a8fa28540..a5e88bb3d2f 100644
--- a/homeassistant/components/toon/sensor.py
+++ b/homeassistant/components/toon/sensor.py
@@ -2,7 +2,7 @@
import logging
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT
+from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT, UNIT_PERCENTAGE
from homeassistant.helpers.typing import HomeAssistantType
from . import (
@@ -13,7 +13,7 @@ from . import (
ToonGasMeterDeviceEntity,
ToonSolarDeviceEntity,
)
-from .const import CURRENCY_EUR, DATA_TOON, DOMAIN, RATIO_PERCENT, VOLUME_CM3, VOLUME_M3
+from .const import CURRENCY_EUR, DATA_TOON, DOMAIN, VOLUME_CM3, VOLUME_M3
_LOGGER = logging.getLogger(__name__)
@@ -195,7 +195,7 @@ async def async_setup_entry(
"current_modulation_level",
"Boiler Modulation Level",
"mdi:percent",
- RATIO_PERCENT,
+ UNIT_PERCENTAGE,
)
]
)
diff --git a/homeassistant/components/toon/strings.json b/homeassistant/components/toon/strings.json
index 80d71d4e421..20d6ba3d72c 100644
--- a/homeassistant/components/toon/strings.json
+++ b/homeassistant/components/toon/strings.json
@@ -26,7 +26,7 @@
"abort": {
"client_id": "The client ID from the configuration is invalid.",
"client_secret": "The client secret from the configuration is invalid.",
- "unknown_auth_fail": "Unexpected error occured, while authenticating.",
+ "unknown_auth_fail": "Unexpected error occurred, while authenticating.",
"no_agreements": "This account has no Toon displays.",
"no_app": "You need to configure Toon before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/toon/)."
}
diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json
index 967115e721a..4675ef0ffaf 100644
--- a/homeassistant/components/totalconnect/manifest.json
+++ b/homeassistant/components/totalconnect/manifest.json
@@ -2,7 +2,7 @@
"domain": "totalconnect",
"name": "Honeywell Total Connect Alarm",
"documentation": "https://www.home-assistant.io/integrations/totalconnect",
- "requirements": ["total_connect_client==0.50"],
+ "requirements": ["total_connect_client==0.54.1"],
"dependencies": [],
"codeowners": ["@austinmroczek"]
}
diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py
index 7f23d6cf31e..b6e829750e9 100644
--- a/homeassistant/components/traccar/device_tracker.py
+++ b/homeassistant/components/traccar/device_tracker.py
@@ -371,11 +371,6 @@ class TraccarEntity(TrackerEntity, RestoreEntity):
"""Return the name of the device."""
return self._name
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
@property
def unique_id(self):
"""Return the unique ID."""
diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py
index c3a08ab1675..db12ab0a5cb 100644
--- a/homeassistant/components/tradfri/sensor.py
+++ b/homeassistant/components/tradfri/sensor.py
@@ -1,6 +1,6 @@
"""Support for IKEA Tradfri sensors."""
-from homeassistant.const import DEVICE_CLASS_BATTERY
+from homeassistant.const import DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE
from .base_class import TradfriBaseDevice
from .const import CONF_GATEWAY_ID, KEY_API, KEY_GATEWAY
@@ -47,4 +47,4 @@ class TradfriSensor(TradfriBaseDevice):
@property
def unit_of_measurement(self):
"""Return the unit_of_measurement of the device."""
- return "%"
+ return UNIT_PERCENTAGE
diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py
index 802bb897b96..f2e7387aa6b 100644
--- a/homeassistant/components/trafikverket_weatherstation/sensor.py
+++ b/homeassistant/components/trafikverket_weatherstation/sensor.py
@@ -16,7 +16,9 @@ from homeassistant.const import (
CONF_NAME,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
+ SPEED_METERS_PER_SECOND,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
@@ -71,10 +73,16 @@ SENSOR_TYPES = {
"mdi:flag-triangle",
None,
],
- "wind_speed": ["Wind speed", "m/s", "windforce", "mdi:weather-windy", None],
+ "wind_speed": [
+ "Wind speed",
+ SPEED_METERS_PER_SECOND,
+ "windforce",
+ "mdi:weather-windy",
+ None,
+ ],
"humidity": [
"Humidity",
- "%",
+ UNIT_PERCENTAGE,
"humidity",
"mdi:water-percent",
DEVICE_CLASS_HUMIDITY,
diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py
index 3e6f2407d17..08aa52e3a13 100644
--- a/homeassistant/components/transmission/__init__.py
+++ b/homeassistant/components/transmission/__init__.py
@@ -196,7 +196,7 @@ class TransmissionClient:
def add_options(self):
"""Add options for entry."""
if not self.config_entry.options:
- scan_interval = self.config_entry.data.pop(
+ scan_interval = self.config_entry.data.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
)
options = {CONF_SCAN_INTERVAL: scan_interval}
diff --git a/homeassistant/components/transport_nsw/sensor.py b/homeassistant/components/transport_nsw/sensor.py
index 7c6990de085..e877e2d2e43 100644
--- a/homeassistant/components/transport_nsw/sensor.py
+++ b/homeassistant/components/transport_nsw/sensor.py
@@ -6,7 +6,13 @@ from TransportNSW import TransportNSW
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import ATTR_ATTRIBUTION, ATTR_MODE, CONF_API_KEY, CONF_NAME
+from homeassistant.const import (
+ ATTR_ATTRIBUTION,
+ ATTR_MODE,
+ CONF_API_KEY,
+ CONF_NAME,
+ TIME_MINUTES,
+)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -101,7 +107,7 @@ class TransportNSWSensor(Entity):
@property
def unit_of_measurement(self):
"""Return the unit this state is expressed in."""
- return "min"
+ return TIME_MINUTES
@property
def icon(self):
diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py
index ba698c2b64d..ffbe5239cc9 100644
--- a/homeassistant/components/travisci/sensor.py
+++ b/homeassistant/components/travisci/sensor.py
@@ -12,6 +12,7 @@ from homeassistant.const import (
CONF_API_KEY,
CONF_MONITORED_CONDITIONS,
CONF_SCAN_INTERVAL,
+ TIME_SECONDS,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -30,7 +31,7 @@ SCAN_INTERVAL = timedelta(seconds=30)
# sensor_type [ description, unit, icon ]
SENSOR_TYPES = {
"last_build_id": ["Last Build ID", "", "mdi:account-card-details"],
- "last_build_duration": ["Last Build Duration", "sec", "mdi:timelapse"],
+ "last_build_duration": ["Last Build Duration", TIME_SECONDS, "mdi:timelapse"],
"last_build_finished_at": ["Last Build Finished At", "", "mdi:timetable"],
"last_build_started_at": ["Last Build Started At", "", "mdi:timetable"],
"last_build_state": ["Last Build State", "", "mdi:github-circle"],
diff --git a/homeassistant/components/tts/manifest.json b/homeassistant/components/tts/manifest.json
index 215a16fd4cf..817ca00a818 100644
--- a/homeassistant/components/tts/manifest.json
+++ b/homeassistant/components/tts/manifest.json
@@ -5,5 +5,5 @@
"requirements": ["mutagen==1.43.0"],
"dependencies": ["http"],
"after_dependencies": ["media_player"],
- "codeowners": ["@robbiet480"]
+ "codeowners": ["@pvizeli"]
}
diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py
index 1bf66810e5b..68b7d5dce21 100644
--- a/homeassistant/components/twitch/sensor.py
+++ b/homeassistant/components/twitch/sensor.py
@@ -6,7 +6,7 @@ from twitch import TwitchClient
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import ATTR_FRIENDLY_NAME, CONF_TOKEN
+from homeassistant.const import CONF_TOKEN
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -96,10 +96,7 @@ class TwitchSensor(Entity):
@property
def device_state_attributes(self):
"""Return the state attributes."""
- attr = {
- ATTR_FRIENDLY_NAME: self._channel.display_name,
- }
- attr.update(self._statistics)
+ attr = dict(self._statistics)
if self._oauth_enabled:
attr.update(self._subscription)
diff --git a/homeassistant/components/ubee/device_tracker.py b/homeassistant/components/ubee/device_tracker.py
index 6fe7e90f4c7..21d2fd10009 100644
--- a/homeassistant/components/ubee/device_tracker.py
+++ b/homeassistant/components/ubee/device_tracker.py
@@ -24,7 +24,7 @@ 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"
+ "EVW32C-0N", "EVW320B", "EVW321B", "EVW3200-Wifi", "EVW3226@UPC", "DVW32CB"
),
}
)
diff --git a/homeassistant/components/ubee/manifest.json b/homeassistant/components/ubee/manifest.json
index 910a3debc1e..e853c7490db 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.8"],
+ "requirements": ["pyubee==0.9"],
"dependencies": [],
"codeowners": ["@mzdrale"]
}
diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py
index e3c5440c450..77929436283 100644
--- a/homeassistant/components/uk_transport/sensor.py
+++ b/homeassistant/components/uk_transport/sensor.py
@@ -1,8 +1,4 @@
-"""Support for UK public transport data provided by transportapi.com.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/sensor.uk_transport/
-"""
+"""Support for UK public transport data provided by transportapi.com."""
from datetime import datetime, timedelta
import logging
import re
@@ -11,7 +7,7 @@ import requests
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_MODE
+from homeassistant.const import CONF_MODE, TIME_MINUTES
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle
@@ -123,7 +119,7 @@ class UkTransportSensor(Entity):
@property
def unit_of_measurement(self):
"""Return the unit this state is expressed in."""
- return "min"
+ return TIME_MINUTES
@property
def icon(self):
diff --git a/homeassistant/components/unifi/.translations/en.json b/homeassistant/components/unifi/.translations/en.json
index f1f96b3c363..9ac01e514bf 100644
--- a/homeassistant/components/unifi/.translations/en.json
+++ b/homeassistant/components/unifi/.translations/en.json
@@ -6,7 +6,8 @@
},
"error": {
"faulty_credentials": "Bad user credentials",
- "service_unavailable": "No service available"
+ "service_unavailable": "No service available",
+ "unknown_client_mac": "No client available on that MAC address"
},
"step": {
"user": {
@@ -34,15 +35,26 @@
"track_wired_clients": "Include wired network clients"
},
"description": "Configure device tracking",
- "title": "UniFi options"
+ "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"
},
"description": "Configure statistics sensors",
- "title": "UniFi options"
+ "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/config_flow.py b/homeassistant/components/unifi/config_flow.py
index 36fa7489e81..e0bb1c3bb9f 100644
--- a/homeassistant/components/unifi/config_flow.py
+++ b/homeassistant/components/unifi/config_flow.py
@@ -16,6 +16,7 @@ import homeassistant.helpers.config_validation as cv
from .const import (
CONF_ALLOW_BANDWIDTH_SENSORS,
+ CONF_BLOCK_CLIENT,
CONF_CONTROLLER,
CONF_DETECTION_TIME,
CONF_SITE_ID,
@@ -30,6 +31,7 @@ from .const import (
from .controller import get_controller
from .errors import AlreadyConfigured, AuthenticationRequired, CannotConnect
+CONF_NEW_CLIENT = "new_client"
DEFAULT_PORT = 8443
DEFAULT_SITE_ID = "default"
DEFAULT_VERIFY_SSL = False
@@ -171,61 +173,117 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow):
"""Initialize UniFi options flow."""
self.config_entry = config_entry
self.options = dict(config_entry.options)
+ self.controller = None
async def async_step_init(self, user_input=None):
"""Manage the UniFi options."""
+ self.controller = get_controller_from_config_entry(self.hass, self.config_entry)
+ self.options[CONF_BLOCK_CLIENT] = self.controller.option_block_clients
return await self.async_step_device_tracker()
async def async_step_device_tracker(self, user_input=None):
"""Manage the device tracker options."""
if user_input is not None:
self.options.update(user_input)
- return await self.async_step_statistics_sensors()
+ return await self.async_step_client_control()
- controller = get_controller_from_config_entry(self.hass, self.config_entry)
-
- ssid_filter = {wlan: wlan for wlan in controller.api.wlans}
+ ssid_filter = {wlan: wlan for wlan in self.controller.api.wlans}
return self.async_show_form(
step_id="device_tracker",
data_schema=vol.Schema(
{
vol.Optional(
- CONF_TRACK_CLIENTS, default=controller.option_track_clients,
+ CONF_TRACK_CLIENTS,
+ default=self.controller.option_track_clients,
): bool,
vol.Optional(
CONF_TRACK_WIRED_CLIENTS,
- default=controller.option_track_wired_clients,
+ default=self.controller.option_track_wired_clients,
): bool,
vol.Optional(
- CONF_TRACK_DEVICES, default=controller.option_track_devices,
+ CONF_TRACK_DEVICES,
+ default=self.controller.option_track_devices,
): bool,
vol.Optional(
- CONF_SSID_FILTER, default=controller.option_ssid_filter
+ CONF_SSID_FILTER, default=self.controller.option_ssid_filter
): cv.multi_select(ssid_filter),
vol.Optional(
CONF_DETECTION_TIME,
- default=int(controller.option_detection_time.total_seconds()),
+ default=int(
+ self.controller.option_detection_time.total_seconds()
+ ),
): int,
}
),
)
+ async def async_step_client_control(self, user_input=None):
+ """Manage configuration of network access controlled clients."""
+ errors = {}
+
+ if user_input is not None:
+ new_client = user_input.pop(CONF_NEW_CLIENT, None)
+ self.options.update(user_input)
+
+ if new_client:
+ if (
+ new_client in self.controller.api.clients
+ or new_client in self.controller.api.clients_all
+ ):
+ self.options[CONF_BLOCK_CLIENT].append(new_client)
+
+ else:
+ errors["base"] = "unknown_client_mac"
+
+ else:
+ return await self.async_step_statistics_sensors()
+
+ clients_to_block = {}
+
+ for mac in self.options[CONF_BLOCK_CLIENT]:
+
+ name = None
+
+ for clients in [
+ self.controller.api.clients,
+ self.controller.api.clients_all,
+ ]:
+ if mac in clients:
+ name = f"{clients[mac].name or clients[mac].hostname} ({mac})"
+ break
+
+ if not name:
+ name = mac
+
+ clients_to_block[mac] = name
+
+ return self.async_show_form(
+ step_id="client_control",
+ data_schema=vol.Schema(
+ {
+ vol.Optional(
+ CONF_BLOCK_CLIENT, default=self.options[CONF_BLOCK_CLIENT]
+ ): cv.multi_select(clients_to_block),
+ vol.Optional(CONF_NEW_CLIENT): str,
+ }
+ ),
+ errors=errors,
+ )
+
async def async_step_statistics_sensors(self, user_input=None):
"""Manage the statistics sensors options."""
if user_input is not None:
self.options.update(user_input)
return await self._update_options()
- controller = get_controller_from_config_entry(self.hass, self.config_entry)
-
return self.async_show_form(
step_id="statistics_sensors",
data_schema=vol.Schema(
{
vol.Optional(
CONF_ALLOW_BANDWIDTH_SENSORS,
- default=controller.option_allow_bandwidth_sensors,
+ default=self.controller.option_allow_bandwidth_sensors,
): bool
}
),
diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py
index d82b7b49d45..341364063f2 100644
--- a/homeassistant/components/unifi/const.py
+++ b/homeassistant/components/unifi/const.py
@@ -25,11 +25,9 @@ CONF_DONT_TRACK_DEVICES = "dont_track_devices"
CONF_DONT_TRACK_WIRED_CLIENTS = "dont_track_wired_clients"
DEFAULT_ALLOW_BANDWIDTH_SENSORS = False
-DEFAULT_BLOCK_CLIENTS = []
DEFAULT_TRACK_CLIENTS = True
DEFAULT_TRACK_DEVICES = True
DEFAULT_TRACK_WIRED_CLIENTS = True
DEFAULT_DETECTION_TIME = 300
-DEFAULT_SSID_FILTER = []
ATTR_MANUFACTURER = "Ubiquiti Networks"
diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py
index b7cd8e8b6a1..a6981aeddee 100644
--- a/homeassistant/components/unifi/controller.py
+++ b/homeassistant/components/unifi/controller.py
@@ -31,9 +31,7 @@ from .const import (
CONF_TRACK_WIRED_CLIENTS,
CONTROLLER_ID,
DEFAULT_ALLOW_BANDWIDTH_SENSORS,
- DEFAULT_BLOCK_CLIENTS,
DEFAULT_DETECTION_TIME,
- DEFAULT_SSID_FILTER,
DEFAULT_TRACK_CLIENTS,
DEFAULT_TRACK_DEVICES,
DEFAULT_TRACK_WIRED_CLIENTS,
@@ -99,7 +97,7 @@ class UniFiController:
@property
def option_block_clients(self):
"""Config entry option with list of clients to control network access."""
- return self.config_entry.options.get(CONF_BLOCK_CLIENT, DEFAULT_BLOCK_CLIENTS)
+ return self.config_entry.options.get(CONF_BLOCK_CLIENT, [])
@property
def option_track_clients(self):
@@ -130,7 +128,7 @@ class UniFiController:
@property
def option_ssid_filter(self):
"""Config entry option listing what SSIDs are being used to track clients."""
- return self.config_entry.options.get(CONF_SSID_FILTER, DEFAULT_SSID_FILTER)
+ return self.config_entry.options.get(CONF_SSID_FILTER, [])
@property
def mac(self):
@@ -164,7 +162,7 @@ class UniFiController:
WIRELESS_GUEST_CONNECTED,
):
self.update_wireless_clients()
- elif data.get("clients") or data.get("devices"):
+ elif "clients" in data or "devices" in data:
async_dispatcher_send(self.hass, self.signal_update)
@property
diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py
index b398dad488b..e5d3bcfa82b 100644
--- a/homeassistant/components/unifi/device_tracker.py
+++ b/homeassistant/components/unifi/device_tracker.py
@@ -8,6 +8,7 @@ from homeassistant.components.unifi.config_flow import get_controller_from_confi
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.event import async_track_point_in_utc_time
import homeassistant.util.dt as dt_util
from .const import ATTR_MANUFACTURER
@@ -175,6 +176,8 @@ class UniFiClientTracker(UniFiClient, ScannerEntity):
"""Set up tracked client."""
super().__init__(client, controller)
+ self.cancel_scheduled_update = None
+ self.is_disconnected = None
self.wired_bug = None
if self.is_wired != self.client.is_wired:
self.wired_bug = dt_util.utcnow() - self.controller.option_detection_time
@@ -186,6 +189,14 @@ class UniFiClientTracker(UniFiClient, ScannerEntity):
If connected to unwanted ssid return False.
If is_wired and client.is_wired differ it means that the device is offline and UniFi bug shows device as wired.
"""
+
+ @callback
+ def _scheduled_update(now):
+ """Scheduled callback for update."""
+ self.is_disconnected = True
+ self.cancel_scheduled_update = None
+ self.async_schedule_update_ha_state()
+
if (
not self.is_wired
and self.controller.option_ssid_filter
@@ -193,6 +204,28 @@ class UniFiClientTracker(UniFiClient, ScannerEntity):
):
return False
+ if (self.is_wired and self.wired_connection) or (
+ not self.is_wired and self.wireless_connection
+ ):
+ if self.cancel_scheduled_update:
+ self.cancel_scheduled_update()
+ self.cancel_scheduled_update = None
+
+ self.is_disconnected = False
+
+ if (self.is_wired and self.wired_connection is False) or (
+ not self.is_wired and self.wireless_connection is False
+ ):
+ if not self.is_disconnected and not self.cancel_scheduled_update:
+ self.cancel_scheduled_update = async_track_point_in_utc_time(
+ self.hass,
+ _scheduled_update,
+ dt_util.utcnow() + self.controller.option_detection_time,
+ )
+
+ if self.is_disconnected is not None:
+ return not self.is_disconnected
+
if self.is_wired != self.client.is_wired:
if not self.wired_bug:
self.wired_bug = dt_util.utcnow()
diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json
index 85633ebf131..01aa245f608 100644
--- a/homeassistant/components/unifi/manifest.json
+++ b/homeassistant/components/unifi/manifest.json
@@ -4,7 +4,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/unifi",
"requirements": [
- "aiounifi==14"
+ "aiounifi==15"
],
"dependencies": [],
"codeowners": [
diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py
index 942b0ef6779..1b6667f2e80 100644
--- a/homeassistant/components/unifi/sensor.py
+++ b/homeassistant/components/unifi/sensor.py
@@ -2,6 +2,7 @@
import logging
from homeassistant.components.unifi.config_flow import get_controller_from_config_entry
+from homeassistant.const import DATA_BYTES
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -9,9 +10,6 @@ from .unifi_client import UniFiClient
LOGGER = logging.getLogger(__name__)
-ATTR_RECEIVING = "receiving"
-ATTR_TRANSMITTING = "transmitting"
-
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Sensor platform doesn't support configuration through configuration.yaml."""
@@ -115,6 +113,11 @@ class UniFiRxBandwidthSensor(UniFiClient):
"""Return a unique identifier for this bandwidth sensor."""
return f"rx-{self.client.mac}"
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ return DATA_BYTES
+
class UniFiTxBandwidthSensor(UniFiRxBandwidthSensor):
"""Transmitting bandwidth sensor."""
diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json
index e652b60ee32..58728225de7 100644
--- a/homeassistant/components/unifi/strings.json
+++ b/homeassistant/components/unifi/strings.json
@@ -16,7 +16,8 @@
},
"error": {
"faulty_credentials": "Bad user credentials",
- "service_unavailable": "No service available"
+ "service_unavailable": "No service available",
+ "unknown_client_mac": "No client available on that MAC address"
},
"abort": {
"already_configured": "Controller site is already configured",
@@ -37,15 +38,26 @@
"track_wired_clients": "Include wired network clients"
},
"description": "Configure device tracking",
- "title": "UniFi options"
+ "title": "UniFi options 1/3"
+ },
+ "client_control": {
+ "data": {
+ "block_client": "Network access controlled clients",
+ "new_client": "Add new client 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"
},
"description": "Configure statistics sensors",
- "title": "UniFi options"
+ "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/switch.py b/homeassistant/components/unifi/switch.py
index 941f4f8ab84..0df019de02c 100644
--- a/homeassistant/components/unifi/switch.py
+++ b/homeassistant/components/unifi/switch.py
@@ -4,7 +4,6 @@ import logging
from homeassistant.components.switch import SwitchDevice
from homeassistant.components.unifi.config_flow import get_controller_from_config_entry
from homeassistant.core import callback
-from homeassistant.helpers import entity_registry
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.restore_state import RestoreEntity
@@ -30,10 +29,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
switches = {}
switches_off = []
- registry = await entity_registry.async_get_registry(hass)
+ option_block_clients = controller.option_block_clients
+
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
# Restore clients that is not a part of active clients list.
- for entity in registry.entities.values():
+ for entity in entity_registry.entities.values():
if (
entity.config_entry_id == config_entry.entry_id
@@ -61,6 +62,43 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async_dispatcher_connect(hass, controller.signal_update, update_controller)
)
+ @callback
+ def options_updated():
+ """Manage entities affected by config entry options."""
+ nonlocal option_block_clients
+
+ update = set()
+ remove = set()
+
+ if option_block_clients != controller.option_block_clients:
+ option_block_clients = controller.option_block_clients
+
+ for block_client_id, entity in switches.items():
+ if not isinstance(entity, UniFiBlockClientSwitch):
+ continue
+
+ if entity.client.mac in option_block_clients:
+ update.add(block_client_id)
+ else:
+ remove.add(block_client_id)
+
+ for block_client_id in remove:
+ entity = switches.pop(block_client_id)
+
+ if entity_registry.async_is_registered(entity.entity_id):
+ entity_registry.async_remove(entity.entity_id)
+
+ hass.async_create_task(entity.async_remove())
+
+ if len(update) != len(option_block_clients):
+ update_controller()
+
+ controller.listeners.append(
+ async_dispatcher_connect(
+ hass, controller.signal_options_update, options_updated
+ )
+ )
+
update_controller()
switches_off.clear()
@@ -74,15 +112,21 @@ def add_entities(controller, async_add_entities, switches, switches_off):
# block client
for client_id in controller.option_block_clients:
+ client = None
block_client_id = f"block-{client_id}"
if block_client_id in switches:
continue
- if client_id not in controller.api.clients_all:
+ if client_id in controller.api.clients:
+ client = controller.api.clients[client_id]
+
+ elif client_id in controller.api.clients_all:
+ client = controller.api.clients_all[client_id]
+
+ if not client:
continue
- client = controller.api.clients_all[client_id]
switches[block_client_id] = UniFiBlockClientSwitch(client, controller)
new_switches.append(switches[block_client_id])
@@ -218,7 +262,7 @@ class UniFiPOEClientSwitch(UniFiClient, SwitchDevice, RestoreEntity):
"""Shortcut to the switch port that client is connected to."""
try:
return self.device.ports[self.client.sw_port]
- except TypeError:
+ except (AttributeError, KeyError, TypeError):
LOGGER.warning(
"Entity %s reports faulty device %s or port %s",
self.entity_id,
@@ -238,7 +282,7 @@ class UniFiBlockClientSwitch(UniFiClient, SwitchDevice):
@property
def is_on(self):
"""Return true if client is allowed to connect."""
- return not self.client.blocked
+ return not self.is_blocked
async def async_turn_on(self, **kwargs):
"""Turn on connectivity for client."""
@@ -247,3 +291,10 @@ class UniFiBlockClientSwitch(UniFiClient, SwitchDevice):
async def async_turn_off(self, **kwargs):
"""Turn off connectivity for client."""
await self.controller.api.clients.async_block(self.client.mac)
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ if self.is_blocked:
+ return "mdi:network-off"
+ return "mdi:network"
diff --git a/homeassistant/components/unifi/unifi_client.py b/homeassistant/components/unifi/unifi_client.py
index f9e77d47c0e..b46771e574b 100644
--- a/homeassistant/components/unifi/unifi_client.py
+++ b/homeassistant/components/unifi/unifi_client.py
@@ -2,6 +2,18 @@
import logging
+from aiounifi.api import SOURCE_EVENT
+from aiounifi.events import (
+ WIRED_CLIENT_BLOCKED,
+ WIRED_CLIENT_CONNECTED,
+ WIRED_CLIENT_DISCONNECTED,
+ WIRED_CLIENT_UNBLOCKED,
+ WIRELESS_CLIENT_BLOCKED,
+ WIRELESS_CLIENT_CONNECTED,
+ WIRELESS_CLIENT_DISCONNECTED,
+ WIRELESS_CLIENT_UNBLOCKED,
+)
+
from homeassistant.core import callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -9,6 +21,11 @@ from homeassistant.helpers.entity import Entity
LOGGER = logging.getLogger(__name__)
+CLIENT_BLOCKED = (WIRED_CLIENT_BLOCKED, WIRELESS_CLIENT_BLOCKED)
+CLIENT_UNBLOCKED = (WIRED_CLIENT_UNBLOCKED, WIRELESS_CLIENT_UNBLOCKED)
+WIRED_CLIENT = (WIRED_CLIENT_CONNECTED, WIRED_CLIENT_DISCONNECTED)
+WIRELESS_CLIENT = (WIRELESS_CLIENT_CONNECTED, WIRELESS_CLIENT_DISCONNECTED)
+
class UniFiClient(Entity):
"""Base class for UniFi clients."""
@@ -18,7 +35,11 @@ class UniFiClient(Entity):
self.client = client
self.controller = controller
self.listeners = []
+
self.is_wired = self.client.mac not in controller.wireless_clients
+ self.is_blocked = self.client.blocked
+ self.wired_connection = None
+ self.wireless_connection = None
async def async_added_to_hass(self) -> None:
"""Client entity created."""
@@ -41,6 +62,22 @@ class UniFiClient(Entity):
"""Update the clients state."""
if self.is_wired and self.client.mac in self.controller.wireless_clients:
self.is_wired = False
+
+ if self.client.last_updated == SOURCE_EVENT:
+
+ if self.client.event.event in WIRELESS_CLIENT:
+ self.wireless_connection = (
+ self.client.event.event == WIRELESS_CLIENT_CONNECTED
+ )
+
+ elif self.client.event.event in WIRED_CLIENT:
+ self.wired_connection = (
+ self.client.event.event == WIRED_CLIENT_CONNECTED
+ )
+
+ elif self.client.event.event in CLIENT_BLOCKED + CLIENT_UNBLOCKED:
+ self.is_blocked = self.client.event.event in CLIENT_BLOCKED
+
LOGGER.debug("Updating client %s %s", self.entity_id, self.client.mac)
self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/updater/__init__.py b/homeassistant/components/updater/__init__.py
index 0a2c6697f69..5771a4f0cfe 100644
--- a/homeassistant/components/updater/__init__.py
+++ b/homeassistant/components/updater/__init__.py
@@ -1,12 +1,10 @@
"""Support to check for available updates."""
-import asyncio
from datetime import timedelta
from distutils.version import StrictVersion
import json
import logging
import uuid
-import aiohttp
import async_timeout
from distro import linux_distribution # pylint: disable=import-error
import voluptuous as vol
@@ -156,29 +154,27 @@ async def get_newest_version(hass, huuid, include_components):
info_object = {}
session = async_get_clientsession(hass)
- try:
- with async_timeout.timeout(5):
- req = await session.post(UPDATER_URL, json=info_object)
- _LOGGER.info(
- (
- "Submitted analytics to Home Assistant servers. "
- "Information submitted includes %s"
- ),
- info_object,
- )
- except (asyncio.TimeoutError, aiohttp.ClientError):
- _LOGGER.error("Could not contact Home Assistant Update to check for updates")
- raise update_coordinator.UpdateFailed
+
+ with async_timeout.timeout(15):
+ req = await session.post(UPDATER_URL, json=info_object)
+
+ _LOGGER.info(
+ (
+ "Submitted analytics to Home Assistant servers. "
+ "Information submitted includes %s"
+ ),
+ info_object,
+ )
try:
res = await req.json()
except ValueError:
- _LOGGER.error("Received invalid JSON from Home Assistant Update")
- raise update_coordinator.UpdateFailed
+ raise update_coordinator.UpdateFailed(
+ "Received invalid JSON from Home Assistant Update"
+ )
try:
res = RESPONSE_SCHEMA(res)
return res["version"], res["release-notes"]
- except vol.Invalid:
- _LOGGER.error("Got unexpected response: %s", res)
- raise update_coordinator.UpdateFailed
+ except vol.Invalid as err:
+ raise update_coordinator.UpdateFailed(f"Got unexpected response: {err}")
diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py
index 9a7e06738db..ce97c7944c6 100644
--- a/homeassistant/components/upnp/__init__.py
+++ b/homeassistant/components/upnp/__init__.py
@@ -151,9 +151,10 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry):
return False
# 'register'/save UDN
- config_entry.data["udn"] = device.udn
hass.data[DOMAIN]["devices"][device.udn] = device
- hass.config_entries.async_update_entry(entry=config_entry, data=config_entry.data)
+ hass.config_entries.async_update_entry(
+ entry=config_entry, data={**config_entry.data, "udn": device.udn}
+ )
# create device registry entry
device_registry = await dr.async_get_registry(hass)
diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py
index b144d2b96ed..474170050c3 100644
--- a/homeassistant/components/upnp/device.py
+++ b/homeassistant/components/upnp/device.py
@@ -142,16 +142,28 @@ class Device:
async def async_get_total_bytes_received(self):
"""Get total bytes received."""
- return await self._igd_device.async_get_total_bytes_received()
+ try:
+ return await self._igd_device.async_get_total_bytes_received()
+ except asyncio.TimeoutError:
+ _LOGGER.warning("Timeout during get_total_bytes_received")
async def async_get_total_bytes_sent(self):
"""Get total bytes sent."""
- return await self._igd_device.async_get_total_bytes_sent()
+ try:
+ return await self._igd_device.async_get_total_bytes_sent()
+ except asyncio.TimeoutError:
+ _LOGGER.warning("Timeout during get_total_bytes_sent")
async def async_get_total_packets_received(self):
"""Get total packets received."""
- return await self._igd_device.async_get_total_packets_received()
+ try:
+ return await self._igd_device.async_get_total_packets_received()
+ except asyncio.TimeoutError:
+ _LOGGER.warning("Timeout during get_total_packets_received")
async def async_get_total_packets_sent(self):
"""Get total packets sent."""
- return await self._igd_device.async_get_total_packets_sent()
+ try:
+ return await self._igd_device.async_get_total_packets_sent()
+ except asyncio.TimeoutError:
+ _LOGGER.warning("Timeout during get_total_packets_sent")
diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json
index 1e55d60f95e..47ad465eb36 100644
--- a/homeassistant/components/upnp/manifest.json
+++ b/homeassistant/components/upnp/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/upnp",
"requirements": ["async-upnp-client==0.14.12"],
"dependencies": [],
- "codeowners": ["@robbiet480"]
+ "codeowners": ["@StevenLooman"]
}
diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py
index db121678d93..9632997ac1b 100644
--- a/homeassistant/components/upnp/sensor.py
+++ b/homeassistant/components/upnp/sensor.py
@@ -1,11 +1,14 @@
"""Support for UPnP/IGD Sensors."""
+from datetime import timedelta
import logging
+from homeassistant.const import DATA_BYTES, DATA_KIBIBYTES, TIME_SECONDS
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.util import Throttle
import homeassistant.util.dt as dt_util
from .const import DOMAIN as DOMAIN_UPNP, SIGNAL_REMOVE_SENSOR
@@ -18,15 +21,17 @@ PACKETS_RECEIVED = "packets_received"
PACKETS_SENT = "packets_sent"
SENSOR_TYPES = {
- BYTES_RECEIVED: {"name": "bytes received", "unit": "bytes"},
- BYTES_SENT: {"name": "bytes sent", "unit": "bytes"},
+ BYTES_RECEIVED: {"name": "bytes received", "unit": DATA_BYTES},
+ BYTES_SENT: {"name": "bytes sent", "unit": DATA_BYTES},
PACKETS_RECEIVED: {"name": "packets received", "unit": "packets"},
PACKETS_SENT: {"name": "packets sent", "unit": "packets"},
}
IN = "received"
OUT = "sent"
-KBYTE = 1024
+KIBIBYTE = 1024
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
async def async_setup_platform(
@@ -141,6 +146,7 @@ class RawUPnPIGDSensor(UpnpSensor):
"""Return the unit of measurement of this entity, if any."""
return self._type["unit"]
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self):
"""Get the latest information from the IGD."""
if self._type_name == BYTES_RECEIVED:
@@ -192,7 +198,7 @@ class PerSecondUPnPIGDSensor(UpnpSensor):
@property
def unit_of_measurement(self) -> str:
"""Return the unit of measurement of this entity, if any."""
- return f"{self.unit}/s"
+ return f"{self.unit}/{TIME_SECONDS}"
def _is_overflowed(self, new_value) -> bool:
"""Check if value has overflowed."""
@@ -225,7 +231,7 @@ class KBytePerSecondUPnPIGDSensor(PerSecondUPnPIGDSensor):
@property
def unit(self) -> str:
"""Get unit we are measuring in."""
- return "kB"
+ return DATA_KIBIBYTES
async def _async_fetch_value(self) -> float:
"""Fetch value from device."""
@@ -240,7 +246,7 @@ class KBytePerSecondUPnPIGDSensor(PerSecondUPnPIGDSensor):
if self._state is None:
return None
- return format(float(self._state / KBYTE), ".1f")
+ return format(float(self._state / KIBIBYTE), ".1f")
class PacketsPerSecondUPnPIGDSensor(PerSecondUPnPIGDSensor):
diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py
index 23d39204f9c..5be7dcf9b69 100644
--- a/homeassistant/components/utility_meter/const.py
+++ b/homeassistant/components/utility_meter/const.py
@@ -23,6 +23,7 @@ CONF_TARIFF = "tariff"
CONF_TARIFF_ENTITY = "tariff_entity"
ATTR_TARIFF = "tariff"
+ATTR_VALUE = "value"
SIGNAL_START_PAUSE_METER = "utility_meter_start_pause"
SIGNAL_RESET_METER = "utility_meter_reset"
@@ -30,3 +31,4 @@ SIGNAL_RESET_METER = "utility_meter_reset"
SERVICE_RESET = "reset"
SERVICE_SELECT_TARIFF = "select_tariff"
SERVICE_SELECT_NEXT_TARIFF = "next_tariff"
+SERVICE_CALIBRATE_METER = "calibrate"
diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py
index 8c47e716b80..ad82cd9e79f 100644
--- a/homeassistant/components/utility_meter/sensor.py
+++ b/homeassistant/components/utility_meter/sensor.py
@@ -3,6 +3,8 @@ from datetime import date, timedelta
from decimal import Decimal, DecimalException
import logging
+import voluptuous as vol
+
from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
CONF_NAME,
@@ -11,6 +13,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import callback
+from homeassistant.helpers import entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.event import (
async_track_state_change,
@@ -20,6 +23,7 @@ from homeassistant.helpers.restore_state import RestoreEntity
import homeassistant.util.dt as dt_util
from .const import (
+ ATTR_VALUE,
CONF_METER,
CONF_METER_NET_CONSUMPTION,
CONF_METER_OFFSET,
@@ -32,6 +36,7 @@ from .const import (
HOURLY,
MONTHLY,
QUARTERLY,
+ SERVICE_CALIBRATE_METER,
SIGNAL_RESET_METER,
WEEKLY,
YEARLY,
@@ -86,6 +91,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
async_add_entities(meters)
+ platform = entity_platform.current_platform.get()
+
+ platform.async_register_entity_service(
+ SERVICE_CALIBRATE_METER,
+ {vol.Required(ATTR_VALUE): vol.Coerce(float)},
+ "async_calibrate",
+ )
+
class UtilityMeterSensor(RestoreEntity):
"""Representation of an utility meter sensor."""
@@ -206,6 +219,12 @@ class UtilityMeterSensor(RestoreEntity):
self._state = 0
await self.async_update_ha_state()
+ async def async_calibrate(self, value):
+ """Calibrate the Utility Meter with a given value."""
+ _LOGGER.debug("Calibrate %s = %s", self._name, value)
+ self._state = Decimal(value)
+ self.async_write_ha_state()
+
async def async_added_to_hass(self):
"""Handle entity which will be added."""
await super().async_added_to_hass()
diff --git a/homeassistant/components/utility_meter/services.yaml b/homeassistant/components/utility_meter/services.yaml
index 5437f4b83a6..42522f81876 100644
--- a/homeassistant/components/utility_meter/services.yaml
+++ b/homeassistant/components/utility_meter/services.yaml
@@ -23,3 +23,13 @@ select_tariff:
tariff:
description: Name of the tariff to switch to
example: 'offpeak'
+
+calibrate:
+ description: calibrates an utility meter.
+ fields:
+ entity_id:
+ description: Name of the entity to calibrate
+ example: 'utility_meter.energy'
+ value:
+ description: Value to which set the meter
+ example: '100'
\ No newline at end of file
diff --git a/homeassistant/components/vacuum/manifest.json b/homeassistant/components/vacuum/manifest.json
index 895311ae5b6..a6f7ddb2bda 100644
--- a/homeassistant/components/vacuum/manifest.json
+++ b/homeassistant/components/vacuum/manifest.json
@@ -3,6 +3,6 @@
"name": "Vacuum",
"documentation": "https://www.home-assistant.io/integrations/vacuum",
"requirements": [],
- "dependencies": ["group"],
+ "dependencies": [],
"codeowners": []
}
diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py
index f7be502cecb..5bf9b8061ad 100644
--- a/homeassistant/components/vallox/sensor.py
+++ b/homeassistant/components/vallox/sensor.py
@@ -8,6 +8,7 @@ from homeassistant.const import (
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_TIMESTAMP,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -39,7 +40,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
state_proxy=state_proxy,
metric_key="A_CYC_FAN_SPEED",
device_class=None,
- unit_of_measurement="%",
+ unit_of_measurement=UNIT_PERCENTAGE,
icon="mdi:fan",
),
ValloxSensor(
@@ -79,7 +80,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
state_proxy=state_proxy,
metric_key="A_CYC_RH_VALUE",
device_class=DEVICE_CLASS_HUMIDITY,
- unit_of_measurement="%",
+ unit_of_measurement=UNIT_PERCENTAGE,
icon=None,
),
ValloxFilterRemainingSensor(
diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py
index e409a123887..9ac0a36ff9c 100644
--- a/homeassistant/components/vera/sensor.py
+++ b/homeassistant/components/vera/sensor.py
@@ -5,7 +5,7 @@ import logging
import pyvera as veraApi
from homeassistant.components.sensor import ENTITY_ID_FORMAT
-from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
+from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_PERCENTAGE
from homeassistant.helpers.entity import Entity
from homeassistant.util import convert
@@ -54,7 +54,7 @@ class VeraSensor(VeraDevice, Entity):
if self.vera_device.category == veraApi.CATEGORY_UV_SENSOR:
return "level"
if self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR:
- return "%"
+ return UNIT_PERCENTAGE
if self.vera_device.category == veraApi.CATEGORY_POWER_METER:
return "watts"
diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py
index 5735cea335e..384042b7210 100644
--- a/homeassistant/components/verisure/sensor.py
+++ b/homeassistant/components/verisure/sensor.py
@@ -1,7 +1,7 @@
"""Support for Verisure sensors."""
import logging
-from homeassistant.const import TEMP_CELSIUS
+from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE
from homeassistant.helpers.entity import Entity
from . import CONF_HYDROMETERS, CONF_MOUSE, CONF_THERMOMETERS, HUB as hub
@@ -130,7 +130,7 @@ class VerisureHygrometer(Entity):
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity."""
- return "%"
+ return UNIT_PERCENTAGE
# pylint: disable=no-self-use
def update(self):
diff --git a/homeassistant/components/vesync/.translations/lv.json b/homeassistant/components/vesync/.translations/lv.json
new file mode 100644
index 00000000000..eab98211e14
--- /dev/null
+++ b/homeassistant/components/vesync/.translations/lv.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "Parole",
+ "username": "E-pasta adrese"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py
index ef05bcf2adb..783581a0755 100644
--- a/homeassistant/components/viaggiatreno/sensor.py
+++ b/homeassistant/components/viaggiatreno/sensor.py
@@ -7,7 +7,7 @@ import async_timeout
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import ATTR_ATTRIBUTION
+from homeassistant.const import ATTR_ATTRIBUTION, TIME_MINUTES
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -177,5 +177,5 @@ class ViaggiaTrenoSensor(Entity):
self._unit = ""
else:
self._state = res.get("ritardo")
- self._unit = "min"
+ self._unit = TIME_MINUTES
self._icon = ICON
diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py
index 1b101cc7612..ef5533523f8 100644
--- a/homeassistant/components/vicare/climate.py
+++ b/homeassistant/components/vicare/climate.py
@@ -1,4 +1,5 @@
"""Viessmann ViCare climate device."""
+from datetime import timedelta
import logging
import requests
@@ -79,6 +80,9 @@ 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/water_heater.py b/homeassistant/components/vicare/water_heater.py
index eea3d81faf6..fdac2962739 100644
--- a/homeassistant/components/vicare/water_heater.py
+++ b/homeassistant/components/vicare/water_heater.py
@@ -1,4 +1,5 @@
"""Viessmann ViCare water_heater device."""
+from datetime import timedelta
import logging
import requests
@@ -42,6 +43,9 @@ 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/const.py b/homeassistant/components/vilfo/const.py
index 1a40b8430d7..0c6414ce99a 100644
--- a/homeassistant/components/vilfo/const.py
+++ b/homeassistant/components/vilfo/const.py
@@ -1,5 +1,5 @@
"""Constants for the Vilfo Router integration."""
-from homeassistant.const import DEVICE_CLASS_TIMESTAMP
+from homeassistant.const import DEVICE_CLASS_TIMESTAMP, UNIT_PERCENTAGE
DOMAIN = "vilfo"
@@ -18,12 +18,10 @@ ROUTER_DEFAULT_MODEL = "Vilfo Router"
ROUTER_DEFAULT_NAME = "Vilfo Router"
ROUTER_MANUFACTURER = "Vilfo AB"
-UNIT_PERCENT = "%"
-
SENSOR_TYPES = {
ATTR_LOAD: {
ATTR_LABEL: "Load",
- ATTR_UNIT: UNIT_PERCENT,
+ ATTR_UNIT: UNIT_PERCENTAGE,
ATTR_ICON: "mdi:memory",
ATTR_API_DATA_FIELD: ATTR_API_DATA_FIELD_LOAD,
},
diff --git a/homeassistant/components/vizio/.translations/ca.json b/homeassistant/components/vizio/.translations/ca.json
index 834138e9221..007834a08e3 100644
--- a/homeassistant/components/vizio/.translations/ca.json
+++ b/homeassistant/components/vizio/.translations/ca.json
@@ -12,11 +12,24 @@
},
"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)."
},
"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."
+ },
+ "pairing_complete": {
+ "description": "El dispositiu Vizio SmartCast est\u00e0 connectat a Home Assistant."
+ },
+ "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}**'."
+ },
"user": {
"data": {
"access_token": "Testimoni d'acc\u00e9s",
diff --git a/homeassistant/components/vizio/.translations/de.json b/homeassistant/components/vizio/.translations/de.json
index ead4ed4828b..6162a27805e 100644
--- a/homeassistant/components/vizio/.translations/de.json
+++ b/homeassistant/components/vizio/.translations/de.json
@@ -15,6 +15,18 @@
"tv_needs_token": "Wenn der Ger\u00e4tetyp \"TV\" ist, wird ein g\u00fcltiger Zugriffstoken ben\u00f6tigt."
},
"step": {
+ "pair_tv": {
+ "data": {
+ "pin": "PIN"
+ },
+ "title": "Schlie\u00dfen Sie den Pairing-Prozess ab"
+ },
+ "pairing_complete": {
+ "title": "Kopplung abgeschlossen"
+ },
+ "pairing_complete_import": {
+ "title": "Kopplung abgeschlossen"
+ },
"user": {
"data": {
"access_token": "Zugangstoken",
diff --git a/homeassistant/components/vizio/.translations/en.json b/homeassistant/components/vizio/.translations/en.json
index cee436c9647..294025fddc8 100644
--- a/homeassistant/components/vizio/.translations/en.json
+++ b/homeassistant/components/vizio/.translations/en.json
@@ -12,11 +12,27 @@
},
"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."
},
"step": {
+ "pair_tv": {
+ "data": {
+ "pin": "PIN"
+ },
+ "description": "Your TV should be displaying a code. Enter that code into the form and then continue to the next step to complete the pairing.",
+ "title": "Complete Pairing Process"
+ },
+ "pairing_complete": {
+ "description": "Your Vizio SmartCast device is now connected to Home Assistant.",
+ "title": "Pairing Complete"
+ },
+ "pairing_complete_import": {
+ "description": "Your Vizio SmartCast device is now connected to Home Assistant.\n\nYour Access Token is '**{access_token}**'.",
+ "title": "Pairing Complete"
+ },
"user": {
"data": {
"access_token": "Access Token",
@@ -24,6 +40,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.",
"title": "Setup Vizio SmartCast Device"
}
},
diff --git a/homeassistant/components/vizio/.translations/es.json b/homeassistant/components/vizio/.translations/es.json
index 408d94825f1..af3cc1750ab 100644
--- a/homeassistant/components/vizio/.translations/es.json
+++ b/homeassistant/components/vizio/.translations/es.json
@@ -12,11 +12,27 @@
},
"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."
},
"step": {
+ "pair_tv": {
+ "data": {
+ "pin": "PIN"
+ },
+ "description": "Tu TV debe estar mostrando un c\u00f3digo. Escribe ese c\u00f3digo en el formulario y contin\u00faa con el paso siguiente para completar el emparejamiento.",
+ "title": "Completar Proceso de Emparejamiento"
+ },
+ "pairing_complete": {
+ "description": "Tu dispositivo Vizio SmartCast est\u00e1 ahora conectado a Home Assistant.",
+ "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}**'.",
+ "title": "Emparejamiento Completado"
+ },
"user": {
"data": {
"access_token": "Token de acceso",
@@ -24,6 +40,7 @@
"host": "< Host / IP > : ",
"name": "Nombre"
},
+ "description": "Todos los campos son obligatorios excepto el Token de Acceso. Si decides no proporcionar un Token de Acceso y tu Tipo de Dispositivo es \"tv\", se te llevar\u00e1 por un proceso de emparejamiento con tu dispositivo para que se pueda recuperar un Token de Acceso.\n\nPara pasar por el proceso de emparejamiento, antes de pulsar en Enviar, aseg\u00farese de que tu TV est\u00e9 encendida y conectada a la red. Tambi\u00e9n es necesario poder ver la pantalla.",
"title": "Configurar el cliente de Vizio SmartCast"
}
},
diff --git a/homeassistant/components/vizio/.translations/it.json b/homeassistant/components/vizio/.translations/it.json
index dd27133453e..77c905d7cf5 100644
--- a/homeassistant/components/vizio/.translations/it.json
+++ b/homeassistant/components/vizio/.translations/it.json
@@ -12,11 +12,27 @@
},
"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."
},
"step": {
+ "pair_tv": {
+ "data": {
+ "pin": "PIN"
+ },
+ "description": "La TV dovrebbe visualizzare un codice. Immettere quel codice nel modulo e quindi continuare con il passaggio successivo per completare l'associazione.",
+ "title": "Processo di associazione completo"
+ },
+ "pairing_complete": {
+ "description": "Il dispositivo Vizio SmartCast \u00e8 ora connesso a Home Assistant.",
+ "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}**'.",
+ "title": "Associazione completata"
+ },
"user": {
"data": {
"access_token": "Token di accesso",
@@ -24,7 +40,8 @@
"host": "< Host / IP >: ",
"name": "Nome"
},
- "title": "Installazione del client Vizio SmartCast"
+ "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.",
+ "title": "Configurazione del dispositivo SmartCast Vizio"
}
},
"title": "Vizio SmartCast"
diff --git a/homeassistant/components/vizio/.translations/lb.json b/homeassistant/components/vizio/.translations/lb.json
index 809ae6d4eb5..11df333ce4b 100644
--- a/homeassistant/components/vizio/.translations/lb.json
+++ b/homeassistant/components/vizio/.translations/lb.json
@@ -12,11 +12,27 @@
},
"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."
},
"step": {
+ "pair_tv": {
+ "data": {
+ "pin": "PIN"
+ },
+ "description": "Um TV sollt e Code ugewisen ginn. G\u00ebff d\u00ebse Code an d'Form a fuer weider mam n\u00e4chste Schr\u00ebtt fir d'Kopplung ofzeschl\u00e9issen.",
+ "title": "Kopplungs Prozess ofschl\u00e9issen"
+ },
+ "pairing_complete": {
+ "description": "D\u00e4in Visio SmartCast Apparat ass elo mam Home Assistant verbonnen.",
+ "title": "Kopplung ofgeschloss"
+ },
+ "pairing_complete_import": {
+ "description": "D\u00e4in Visio SmartCast Apparat ass elo mam Home Assistant verbonnen.\n\nD\u00e4in Acc\u00e8s Jeton ass '**{access_token}**'.",
+ "title": "Kopplung ofgeschloss"
+ },
"user": {
"data": {
"access_token": "Acc\u00e8ss Jeton",
@@ -24,6 +40,7 @@
"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"
}
},
diff --git a/homeassistant/components/vizio/.translations/no.json b/homeassistant/components/vizio/.translations/no.json
index 0b92497a5e7..dababdd53f2 100644
--- a/homeassistant/components/vizio/.translations/no.json
+++ b/homeassistant/components/vizio/.translations/no.json
@@ -12,11 +12,27 @@
},
"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."
},
"step": {
+ "pair_tv": {
+ "data": {
+ "pin": "PIN"
+ },
+ "description": "TVen skal vise en kode. Skriv inn denne koden i skjemaet, og fortsett deretter til neste trinn for \u00e5 fullf\u00f8re paringen.",
+ "title": "Fullf\u00f8r Sammenkoblings Prosessen"
+ },
+ "pairing_complete": {
+ "description": "Din Vizio SmartCast-enheten er n\u00e5 koblet til Home Assistant.",
+ "title": "Sammenkoblingen Er Fullf\u00f8rt"
+ },
+ "pairing_complete_import": {
+ "description": "Vizio SmartCast-enheten er n\u00e5 koblet til Home Assistant.\n\nTilgangstokenet er **{access_token}**.",
+ "title": "Sammenkoblingen Er Fullf\u00f8rt"
+ },
"user": {
"data": {
"access_token": "Tilgangstoken",
@@ -24,6 +40,7 @@
"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.",
"title": "Sett opp Vizio SmartCast-enhet"
}
},
diff --git a/homeassistant/components/vizio/.translations/ru.json b/homeassistant/components/vizio/.translations/ru.json
index e8f14e796ba..3e14dd3d750 100644
--- a/homeassistant/components/vizio/.translations/ru.json
+++ b/homeassistant/components/vizio/.translations/ru.json
@@ -12,11 +12,27 @@
},
"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."
},
"step": {
+ "pair_tv": {
+ "data": {
+ "pin": "PIN-\u043a\u043e\u0434"
+ },
+ "description": "\u0412\u0430\u0448 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 \u0441\u0435\u0439\u0447\u0430\u0441 \u0434\u043e\u043b\u0436\u0435\u043d \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u043a\u043e\u0434. \u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u044d\u0442\u043e\u0442 \u043a\u043e\u0434 \u0432 \u0444\u043e\u0440\u043c\u0443, \u0430 \u0437\u0430\u0442\u0435\u043c \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043a \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0435\u043c\u0443 \u0448\u0430\u0433\u0443, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435.",
+ "title": "\u0417\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u0435 \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f"
+ },
+ "pairing_complete": {
+ "description": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Vizio SmartCast \u0442\u0435\u043f\u0435\u0440\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043e \u043a Home Assistant.",
+ "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043e"
+ },
+ "pairing_complete_import": {
+ "description": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Vizio SmartCast \u0442\u0435\u043f\u0435\u0440\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043e \u043a Home Assistant. \n\n\u0412\u0430\u0448 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 - '**{access_token}**'.",
+ "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043e"
+ },
"user": {
"data": {
"access_token": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430",
@@ -24,6 +40,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.",
"title": "Vizio SmartCast"
}
},
diff --git a/homeassistant/components/vizio/.translations/zh-Hant.json b/homeassistant/components/vizio/.translations/zh-Hant.json
index 24128bb1b9e..d2404a80620 100644
--- a/homeassistant/components/vizio/.translations/zh-Hant.json
+++ b/homeassistant/components/vizio/.translations/zh-Hant.json
@@ -12,11 +12,27 @@
},
"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"
},
"step": {
+ "pair_tv": {
+ "data": {
+ "pin": "PIN"
+ },
+ "description": "\u96fb\u8996\u4e0a\u61c9\u8a72\u6703\u986f\u793a\u4e00\u7d44\u4ee3\u78bc\u3002\u65bc\u8868\u683c\u4e2d\u8f38\u5165\u4ee3\u78bc\uff0c\u7136\u5f8c\u7e7c\u7e8c\u4e0b\u4e00\u6b65\u4ee5\u5b8c\u6210\u914d\u5c0d\u3002",
+ "title": "\u5b8c\u6210\u914d\u5c0d\u904e\u7a0b"
+ },
+ "pairing_complete": {
+ "description": "Vizio SmartCast \u8a2d\u5099\u5df2\u7d93\u9023\u7dda\u81f3 Home Assistant\u3002",
+ "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",
+ "title": "\u914d\u5c0d\u5b8c\u6210"
+ },
"user": {
"data": {
"access_token": "\u5b58\u53d6\u5bc6\u9470",
@@ -24,6 +40,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",
"title": "\u8a2d\u5b9a Vizio SmartCast \u8a2d\u5099"
}
},
diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py
index 436ad829d94..a52b395c5c9 100644
--- a/homeassistant/components/vizio/__init__.py
+++ b/homeassistant/components/vizio/__init__.py
@@ -5,32 +5,27 @@ import voluptuous as vol
from homeassistant.components.media_player import DEVICE_CLASS_TV
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
-from .const import DOMAIN, VIZIO_SCHEMA
+from .const import CONF_APPS, CONF_DEVICE_CLASS, DOMAIN, VIZIO_SCHEMA
-def validate_auth(config: ConfigType) -> ConfigType:
- """Validate presence of CONF_ACCESS_TOKEN when CONF_DEVICE_CLASS == DEVICE_CLASS_TV."""
- token = config.get(CONF_ACCESS_TOKEN)
- if config[CONF_DEVICE_CLASS] == DEVICE_CLASS_TV and not token:
+def validate_apps(config: ConfigType) -> ConfigType:
+ """Validate CONF_APPS is only used when CONF_DEVICE_CLASS == DEVICE_CLASS_TV."""
+ if (
+ config.get(CONF_APPS) is not None
+ and config[CONF_DEVICE_CLASS] != DEVICE_CLASS_TV
+ ):
raise vol.Invalid(
- f"When '{CONF_DEVICE_CLASS}' is '{DEVICE_CLASS_TV}' then "
- f"'{CONF_ACCESS_TOKEN}' is required.",
- path=[CONF_ACCESS_TOKEN],
+ f"'{CONF_APPS}' can only be used if {CONF_DEVICE_CLASS}' is '{DEVICE_CLASS_TV}'"
)
return config
CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.All(
- cv.ensure_list, [vol.All(vol.Schema(VIZIO_SCHEMA), validate_auth)]
- )
- },
+ {DOMAIN: vol.All(cv.ensure_list, [vol.All(VIZIO_SCHEMA, validate_apps)])},
extra=vol.ALLOW_EXTRA,
)
diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py
index ea0e9ede237..1eb58c96670 100644
--- a/homeassistant/components/vizio/config_flow.py
+++ b/homeassistant/components/vizio/config_flow.py
@@ -1,4 +1,5 @@
"""Config flow for Vizio."""
+import copy
import logging
from typing import Any, Dict
@@ -7,31 +8,43 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.media_player import DEVICE_CLASS_SPEAKER, DEVICE_CLASS_TV
-from homeassistant.config_entries import ConfigEntry
+from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_ZEROCONF, ConfigEntry
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_DEVICE_CLASS,
+ CONF_EXCLUDE,
CONF_HOST,
+ CONF_INCLUDE,
CONF_NAME,
+ CONF_PIN,
CONF_PORT,
CONF_TYPE,
)
from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from . import validate_auth
from .const import (
+ CONF_APPS,
+ CONF_APPS_TO_INCLUDE_OR_EXCLUDE,
+ CONF_INCLUDE_OR_EXCLUDE,
CONF_VOLUME_STEP,
DEFAULT_DEVICE_CLASS,
DEFAULT_NAME,
DEFAULT_VOLUME_STEP,
+ DEVICE_ID,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
-def _get_config_flow_schema(input_dict: Dict[str, Any] = None) -> vol.Schema:
- """Return schema defaults based on user input/config dict. Retain info already provided for future form views by setting them as defaults in schema."""
+def _get_config_schema(input_dict: Dict[str, Any] = None) -> vol.Schema:
+ """
+ Return schema defaults for init step based on user input/config dict.
+
+ Retain info already provided for future form views by setting them as defaults in schema.
+ """
if input_dict is None:
input_dict = {}
@@ -41,7 +54,7 @@ def _get_config_flow_schema(input_dict: Dict[str, Any] = None) -> vol.Schema:
CONF_NAME, default=input_dict.get(CONF_NAME, DEFAULT_NAME)
): str,
vol.Required(CONF_HOST, default=input_dict.get(CONF_HOST)): str,
- vol.Optional(
+ vol.Required(
CONF_DEVICE_CLASS,
default=input_dict.get(CONF_DEVICE_CLASS, DEFAULT_DEVICE_CLASS),
): vol.All(str, vol.Lower, vol.In([DEVICE_CLASS_TV, DEVICE_CLASS_SPEAKER])),
@@ -53,13 +66,27 @@ def _get_config_flow_schema(input_dict: Dict[str, Any] = None) -> vol.Schema:
)
+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.
+ """
+ if input_dict is None:
+ input_dict = {}
+
+ return vol.Schema(
+ {vol.Required(CONF_PIN, default=input_dict.get(CONF_PIN, "")): str}
+ )
+
+
def _host_is_same(host1: str, host2: str) -> bool:
"""Check if host1 and host2 are the same."""
return host1.split(":")[0] == host2.split(":")[0]
class VizioOptionsConfigFlow(config_entries.OptionsFlow):
- """Handle Transmission client options."""
+ """Handle Vizio options."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize vizio options flow."""
@@ -100,6 +127,23 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Initialize config flow."""
self._user_schema = None
self._must_show_form = None
+ self._ch_type = None
+ self._pairing_token = None
+ self._data = None
+ self._apps = {}
+
+ 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."""
+ # 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)
+
+ if self._apps:
+ input_dict[CONF_APPS] = self._apps
+
+ return self.async_create_entry(title=input_dict[CONF_NAME], data=input_dict)
async def async_step_user(
self, user_input: Dict[str, Any] = None
@@ -109,60 +153,82 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None:
# Store current values in case setup fails and user needs to edit
- self._user_schema = _get_config_flow_schema(user_input)
+ self._user_schema = _get_config_schema(user_input)
# Check if new config entry matches any existing config entries
for entry in self.hass.config_entries.async_entries(DOMAIN):
if _host_is_same(entry.data[CONF_HOST], user_input[CONF_HOST]):
errors[CONF_HOST] = "host_exists"
- break
-
if entry.data[CONF_NAME] == user_input[CONF_NAME]:
errors[CONF_NAME] = "name_exists"
- break
if not errors:
- try:
- # Ensure schema passes custom validation, otherwise catch exception and add error
- validate_auth(user_input)
-
+ # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
+ if self._must_show_form and self.context["source"] == SOURCE_ZEROCONF:
+ # Discovery should always display the config form before trying to
+ # create entry so that user can update default config options
+ self._must_show_form = False
+ elif user_input[
+ CONF_DEVICE_CLASS
+ ] == DEVICE_CLASS_SPEAKER or user_input.get(CONF_ACCESS_TOKEN):
# Ensure config is valid for a device
if not await VizioAsync.validate_ha_config(
user_input[CONF_HOST],
user_input.get(CONF_ACCESS_TOKEN),
user_input[CONF_DEVICE_CLASS],
+ session=async_get_clientsession(self.hass, False),
):
errors["base"] = "cant_connect"
- except vol.Invalid:
- errors["base"] = "tv_needs_token"
- if not errors:
- # Skip validating config and creating entry if form must be shown
- if self._must_show_form:
- self._must_show_form = False
- else:
- # Abort flow if existing entry with same unique ID matches new config entry.
- # Since name and host check have already passed, if an entry already exists,
- # It is likely a reconfigured device.
- unique_id = await VizioAsync.get_unique_id(
- user_input[CONF_HOST],
- user_input.get(CONF_ACCESS_TOKEN),
- user_input[CONF_DEVICE_CLASS],
- )
-
- if await self.async_set_unique_id(
- unique_id=unique_id, raise_on_progress=True
- ):
- return self.async_abort(
- reason="already_setup_with_diff_host_and_name"
+ if not errors:
+ unique_id = await VizioAsync.get_unique_id(
+ user_input[CONF_HOST],
+ user_input.get(CONF_ACCESS_TOKEN),
+ user_input[CONF_DEVICE_CLASS],
+ session=async_get_clientsession(self.hass, False),
)
- return self.async_create_entry(
- title=user_input[CONF_NAME], data=user_input
- )
+ # Set unique ID and abort if unique ID is already configured on an entry or a flow
+ # with the unique ID is already in progress
+ await self.async_set_unique_id(
+ unique_id=unique_id, raise_on_progress=True
+ )
+ self._abort_if_unique_id_configured()
- # Use user_input params as default values for schema if user_input is non-empty, otherwise use default schema
- schema = self._user_schema or _get_config_flow_schema()
+ # 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:
+ # Import should always display the config form if CONF_ACCESS_TOKEN
+ # wasn't included but is needed so that the user can choose to update
+ # their configuration.yaml or to proceed with config flow pairing. We
+ # will also provide contextual message to user explaining why
+ _LOGGER.warning(
+ "Couldn't complete configuration.yaml import: '%s' key is "
+ "missing. Either provide '%s' key in configuration.yaml or "
+ "finish setup by completing configuration via frontend.",
+ CONF_ACCESS_TOKEN,
+ CONF_ACCESS_TOKEN,
+ )
+ self._must_show_form = False
+ else:
+ self._data = copy.deepcopy(user_input)
+ return await self.async_step_pair_tv()
+
+ schema = self._user_schema or _get_config_schema()
+
+ # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
+ if errors and self.context["source"] == SOURCE_IMPORT:
+ # Log an error message if import config flow fails since otherwise failure is silent
+ _LOGGER.error(
+ "configuration.yaml import failure: %s", ", ".join(errors.values())
+ )
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
@@ -172,32 +238,49 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
for entry in self.hass.config_entries.async_entries(DOMAIN):
if _host_is_same(entry.data[CONF_HOST], import_config[CONF_HOST]):
updated_options = {}
- updated_name = {}
+ updated_data = {}
+ remove_apps = False
if entry.data[CONF_NAME] != import_config[CONF_NAME]:
- updated_name[CONF_NAME] = import_config[CONF_NAME]
+ updated_data[CONF_NAME] = import_config[CONF_NAME]
+
+ # Update entry.data[CONF_APPS] if import_config[CONF_APPS] differs, and
+ # pop entry.data[CONF_APPS] if import_config[CONF_APPS] is not specified
+ if entry.data.get(CONF_APPS) != import_config.get(CONF_APPS):
+ if not import_config.get(CONF_APPS):
+ remove_apps = True
+ else:
+ updated_data[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]
- if updated_options or updated_name:
+ if updated_options or updated_data or remove_apps:
new_data = entry.data.copy()
new_options = entry.options.copy()
- if updated_name:
- new_data.update(updated_name)
+ if remove_apps:
+ new_data.pop(CONF_APPS)
+
+ if updated_data:
+ new_data.update(updated_data)
if updated_options:
new_data.update(updated_options)
new_options.update(updated_options)
self.hass.config_entries.async_update_entry(
- entry=entry, data=new_data, options=new_options,
+ entry=entry, data=new_data, options=new_options
)
return self.async_abort(reason="updated_entry")
return self.async_abort(reason="already_setup")
+ self._must_show_form = True
+ # Store config key/value pairs that are not configurable in user step so they
+ # don't get lost on user step
+ if import_config.get(CONF_APPS):
+ self._apps = copy.deepcopy(import_config[CONF_APPS])
return await self.async_step_user(user_input=import_config)
async def async_step_zeroconf(
@@ -228,7 +311,126 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
discovery_info[CONF_HOST]
)
- # Form must be shown after discovery so user can confirm/update configuration before ConfigEntry creation.
+ # Form must be shown after discovery so user can confirm/update configuration
+ # before ConfigEntry creation.
self._must_show_form = True
-
return await self.async_step_user(user_input=discovery_info)
+
+ 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."""
+ errors = {}
+
+ # Start pairing process if it hasn't already started
+ if not self._ch_type and not self._pairing_token:
+ dev = VizioAsync(
+ DEVICE_ID,
+ self._data[CONF_HOST],
+ self._data[CONF_NAME],
+ None,
+ self._data[CONF_DEVICE_CLASS],
+ session=async_get_clientsession(self.hass, False),
+ )
+ pair_data = await dev.start_pair()
+
+ if pair_data:
+ self._ch_type = pair_data.ch_type
+ self._pairing_token = pair_data.token
+ return await self.async_step_pair_tv()
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=_get_config_schema(self._data),
+ errors={"base": "cant_connect"},
+ )
+
+ # Complete pairing process if PIN has been provided
+ if user_input and user_input.get(CONF_PIN):
+ dev = VizioAsync(
+ DEVICE_ID,
+ self._data[CONF_HOST],
+ self._data[CONF_NAME],
+ None,
+ self._data[CONF_DEVICE_CLASS],
+ session=async_get_clientsession(self.hass, False),
+ )
+ pair_data = await dev.pair(
+ self._ch_type, self._pairing_token, user_input[CONF_PIN]
+ )
+
+ if pair_data:
+ self._data[CONF_ACCESS_TOKEN] = pair_data.auth_token
+ self._must_show_form = True
+
+ unique_id = await VizioAsync.get_unique_id(
+ self._data[CONF_HOST],
+ self._data[CONF_ACCESS_TOKEN],
+ self._data[CONF_DEVICE_CLASS],
+ session=async_get_clientsession(self.hass, False),
+ )
+
+ # Set unique ID and abort if unique ID is already configured on an entry or a flow
+ # with the unique ID is already in progress
+ await self.async_set_unique_id(
+ unique_id=unique_id, raise_on_progress=True
+ )
+ self._abort_if_unique_id_configured()
+
+ # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
+ if self.context["source"] == SOURCE_IMPORT:
+ # If user is pairing via config import, show different message
+ return await self.async_step_pairing_complete_import()
+
+ return await self.async_step_tv_apps()
+
+ # If no data was retrieved, it's assumed that the pairing attempt was not
+ # successful
+ errors[CONF_PIN] = "complete_pairing_failed"
+
+ return self.async_show_form(
+ step_id="pair_tv",
+ data_schema=_get_pairing_schema(user_input),
+ 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."""
+ 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",
+ data_schema=vol.Schema({}),
+ description_placeholders={"access_token": self._data[CONF_ACCESS_TOKEN]},
+ )
+
+ async def async_step_tv_apps(
+ 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()
+
+ return await self._create_entry_if_unique(self._data)
+
+ 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()
+ ),
+ }
+ ),
+ )
diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py
index e3ac66e05c3..795f12266fb 100644
--- a/homeassistant/components/vizio/const.py
+++ b/homeassistant/components/vizio/const.py
@@ -1,4 +1,5 @@
"""Constants used by vizio component."""
+from pyvizio import VizioAsync
from pyvizio.const import (
DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER,
DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV,
@@ -19,11 +20,21 @@ from homeassistant.components.media_player.const import (
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_DEVICE_CLASS,
+ CONF_EXCLUDE,
CONF_HOST,
+ CONF_INCLUDE,
CONF_NAME,
)
import homeassistant.helpers.config_validation as cv
+CONF_ADDITIONAL_CONFIGS = "additional_configs"
+CONF_APP_ID = "APP_ID"
+CONF_APPS = "apps"
+CONF_APPS_TO_INCLUDE_OR_EXCLUDE = "apps_to_include_or_exclude"
+CONF_CONFIG = "config"
+CONF_INCLUDE_OR_EXCLUDE = "include_or_exclude"
+CONF_NAME_SPACE = "NAME_SPACE"
+CONF_MESSAGE = "MESSAGE"
CONF_VOLUME_STEP = "volume_step"
DEFAULT_DEVICE_CLASS = DEVICE_CLASS_TV
@@ -69,4 +80,30 @@ VIZIO_SCHEMA = {
vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP): vol.All(
vol.Coerce(int), vol.Range(min=1, max=10)
),
+ vol.Optional(CONF_APPS): vol.All(
+ {
+ vol.Exclusive(CONF_INCLUDE, "apps_filter"): vol.All(
+ cv.ensure_list, [vol.All(cv.string, vol.In(VizioAsync.get_apps_list()))]
+ ),
+ vol.Exclusive(CONF_EXCLUDE, "apps_filter"): vol.All(
+ cv.ensure_list, [vol.All(cv.string, vol.In(VizioAsync.get_apps_list()))]
+ ),
+ vol.Optional(CONF_ADDITIONAL_CONFIGS): vol.All(
+ cv.ensure_list,
+ [
+ {
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_CONFIG): {
+ vol.Required(CONF_APP_ID): cv.string,
+ vol.Required(CONF_NAME_SPACE): vol.Coerce(int),
+ vol.Optional(CONF_MESSAGE, default=None): vol.Or(
+ cv.string, None
+ ),
+ },
+ },
+ ],
+ ),
+ },
+ cv.has_at_least_one_key(CONF_INCLUDE, CONF_EXCLUDE, CONF_ADDITIONAL_CONFIGS),
+ ),
}
diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json
index bf88ed9f437..f1931f6fdb1 100644
--- a/homeassistant/components/vizio/manifest.json
+++ b/homeassistant/components/vizio/manifest.json
@@ -1,8 +1,8 @@
{
"domain": "vizio",
- "name": "Vizio SmartCast TV",
+ "name": "Vizio SmartCast",
"documentation": "https://www.home-assistant.io/integrations/vizio",
- "requirements": ["pyvizio==0.1.21"],
+ "requirements": ["pyvizio==0.1.35"],
"dependencies": [],
"codeowners": ["@raman325"],
"config_flow": true,
diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py
index 7d76505a457..d013f41403a 100644
--- a/homeassistant/components/vizio/media_player.py
+++ b/homeassistant/components/vizio/media_player.py
@@ -1,16 +1,23 @@
"""Vizio SmartCast Device support."""
from datetime import timedelta
import logging
-from typing import Callable, List
+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 homeassistant.components.media_player import MediaPlayerDevice
+from homeassistant.components.media_player import (
+ DEVICE_CLASS_SPEAKER,
+ MediaPlayerDevice,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_DEVICE_CLASS,
+ CONF_EXCLUDE,
CONF_HOST,
+ CONF_INCLUDE,
CONF_NAME,
STATE_OFF,
STATE_ON,
@@ -25,6 +32,8 @@ from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType
from .const import (
+ CONF_ADDITIONAL_CONFIGS,
+ CONF_APPS,
CONF_VOLUME_STEP,
DEFAULT_TIMEOUT,
DEFAULT_VOLUME_STEP,
@@ -51,10 +60,11 @@ 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(
- CONF_VOLUME_STEP, config_entry.data.get(CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP),
+ CONF_VOLUME_STEP, config_entry.data.get(CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP)
)
params = {}
@@ -79,11 +89,13 @@ async def async_setup_entry(
timeout=DEFAULT_TIMEOUT,
)
- if not await device.can_connect():
+ if not await device.can_connect_with_auth_check():
_LOGGER.warning("Failed to connect to %s", host)
raise PlatformNotReady
- entity = VizioDevice(config_entry, device, name, volume_step, device_class)
+ entity = VizioDevice(
+ config_entry, device, name, volume_step, device_class, conf_apps,
+ )
async_add_entities([entity], update_before_add=True)
@@ -98,6 +110,7 @@ class VizioDevice(MediaPlayerDevice):
name: str,
volume_step: int,
device_class: str,
+ conf_apps: Dict[str, List[Any]],
) -> None:
"""Initialize Vizio device."""
self._config_entry = config_entry
@@ -107,8 +120,13 @@ class VizioDevice(MediaPlayerDevice):
self._state = None
self._volume_level = None
self._volume_step = volume_step
+ self._is_muted = None
self._current_input = None
- self._available_inputs = None
+ self._current_app = None
+ self._available_inputs = []
+ self._available_apps = []
+ self._conf_apps = conf_apps
+ self._additional_app_configs = self._conf_apps.get(CONF_ADDITIONAL_CONFIGS, [])
self._device_class = device_class
self._supported_commands = SUPPORTED_COMMANDS[device_class]
self._device = device
@@ -118,10 +136,34 @@ class VizioDevice(MediaPlayerDevice):
self._model = None
self._sw_version = None
+ def _apps_list(self, apps: List[str]) -> List[str]:
+ """Return process apps list based on configured filters."""
+ if self._conf_apps.get(CONF_INCLUDE):
+ return [app for app in apps if app in self._conf_apps[CONF_INCLUDE]]
+
+ if self._conf_apps.get(CONF_EXCLUDE):
+ return [app for app in apps if app not in self._conf_apps[CONF_EXCLUDE]]
+
+ 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:
- self._model = await self._device.get_model()
+ self._model = await self._device.get_model_name()
if not self._sw_version:
self._sw_version = await self._device.get_version()
@@ -145,23 +187,54 @@ class VizioDevice(MediaPlayerDevice):
if not is_on:
self._state = STATE_OFF
self._volume_level = None
+ self._is_muted = None
self._current_input = None
self._available_inputs = None
+ self._current_app = None
+ self._available_apps = None
return
self._state = STATE_ON
- volume = await self._device.get_current_volume(log_api_exception=False)
- if volume is not None:
- self._volume_level = float(volume) / self._max_volume
+ audio_settings = await self._device.get_all_audio_settings(
+ log_api_exception=False
+ )
+ if audio_settings is not None:
+ self._volume_level = float(audio_settings["volume"]) / self._max_volume
+ self._is_muted = audio_settings["mute"].lower() == "on"
input_ = await self._device.get_current_input(log_api_exception=False)
if input_ is not None:
self._current_input = input_
inputs = await self._device.get_inputs_list(log_api_exception=False)
- if inputs is not None:
- self._available_inputs = [input_.name for input_ in inputs]
+
+ # If no inputs returned, end update
+ if not inputs:
+ return
+
+ self._available_inputs = [input_.name for input_ in inputs]
+
+ # Return before setting app variables if INPUT_APPS isn't in available inputs
+ if self._device_class == DEVICE_CLASS_SPEAKER or not any(
+ app for app in INPUT_APPS if app in self._available_inputs
+ ):
+ return
+
+ # Create list of available known apps from known app list after
+ # filtering by CONF_INCLUDE/CONF_EXCLUDE
+ 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()
+
+ def _get_additional_app_names(self) -> List[Dict[str, Any]]:
+ """Return list of additional apps that were included in configuration.yaml."""
+ return [
+ additional_app["name"] for additional_app in self._additional_app_configs
+ ]
@staticmethod
async def _async_send_update_options_signal(
@@ -224,16 +297,47 @@ class VizioDevice(MediaPlayerDevice):
"""Return the volume level of the device."""
return self._volume_level
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._is_muted
+
@property
def source(self) -> str:
"""Return current input of the device."""
+ if self._current_app is not None and self._current_input in INPUT_APPS:
+ return self._current_app
+
return self._current_input
@property
- def source_list(self) -> List:
+ def source_list(self) -> List[str]:
"""Return list of available inputs of the device."""
+ # If Smartcast app is in input list, and the app list has been retrieved,
+ # show the combination with , otherwise just return inputs
+ if self._available_apps:
+ return [
+ *[
+ _input
+ for _input in self._available_inputs
+ if _input not in INPUT_APPS
+ ],
+ *self._available_apps,
+ *self._get_additional_app_names(),
+ ]
+
return self._available_inputs
+ @property
+ def app_id(self) -> Optional[str]:
+ """Return the current app."""
+ return self._current_app
+
+ @property
+ def app_name(self) -> Optional[str]:
+ """Return the friendly name of the current app."""
+ return self._current_app
+
@property
def supported_features(self) -> int:
"""Flag device features that are supported."""
@@ -272,8 +376,10 @@ class VizioDevice(MediaPlayerDevice):
"""Mute the volume."""
if mute:
await self._device.mute_on()
+ self._is_muted = True
else:
await self._device.mute_off()
+ self._is_muted = False
async def async_media_previous_track(self) -> None:
"""Send previous channel command."""
@@ -285,7 +391,18 @@ class VizioDevice(MediaPlayerDevice):
async def async_select_source(self, source: str) -> None:
"""Select input source."""
- await self._device.set_input(source)
+ if source in self._available_inputs:
+ await self._device.set_input(source)
+ elif source in self._get_additional_app_names():
+ await self._device.launch_app_config(
+ **next(
+ app["config"]
+ for app in self._additional_app_configs
+ if app["name"] == source
+ )
+ )
+ elif source in self._available_apps:
+ await self._device.launch_app(source)
async def async_volume_up(self) -> None:
"""Increase volume of the device."""
diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json
index 5a554b7e3db..61db7b49665 100644
--- a/homeassistant/components/vizio/strings.json
+++ b/homeassistant/components/vizio/strings.json
@@ -3,25 +3,44 @@
"title": "Vizio SmartCast",
"step": {
"user": {
- "title": "Setup Vizio SmartCast Client",
+ "title": "Setup Vizio SmartCast Device",
+ "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.",
"data": {
"name": "Name",
"host": ":",
"device_class": "Device Type",
"access_token": "Access Token"
}
+ },
+ "pair_tv": {
+ "title": "Complete Pairing Process",
+ "description": "Your TV should be displaying a code. Enter that code into the form and then continue to the next step to complete the pairing.",
+ "data": {
+ "pin": "PIN"
+ }
+ },
+ "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": {
"host_exists": "Vizio device with specified host already configured.",
"name_exists": "Vizio device with specified name already configured.",
- "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.",
- "tv_needs_token": "When Device Type is `tv` then a valid Access Token is needed."
+ "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.",
+ "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."
},
"abort": {
"already_setup": "This entry has already been setup.",
- "already_setup_with_diff_host_and_name": "This entry appears to have already been setup with a different host and name based on its serial number. Please remove any old entries from your configuration.yaml and from the Integrations menu before reattempting to add this device.",
- "updated_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_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."
}
},
"options": {
diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py
index 369b9c33c0d..90e62c0d951 100644
--- a/homeassistant/components/volumio/media_player.py
+++ b/homeassistant/components/volumio/media_player.py
@@ -1,9 +1,6 @@
"""
Volumio Platform.
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.volumio/
-
Volumio rest API: https://volumio.github.io/docs/API/REST_API.html
"""
import asyncio
diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py
index bf6817c2dfe..e2c92d07f9c 100644
--- a/homeassistant/components/waterfurnace/sensor.py
+++ b/homeassistant/components/waterfurnace/sensor.py
@@ -1,7 +1,7 @@
"""Support for Waterfurnace."""
from homeassistant.components.sensor import ENTITY_ID_FORMAT
-from homeassistant.const import TEMP_FAHRENHEIT
+from homeassistant.const import TEMP_FAHRENHEIT, UNIT_PERCENTAGE
from homeassistant.core import callback
from homeassistant.helpers.entity import Entity
from homeassistant.util import slugify
@@ -34,9 +34,11 @@ SENSORS = [
"Loop Temp", "enteringwatertemp", "mdi:thermometer", TEMP_FAHRENHEIT
),
WFSensorConfig(
- "Humidity Set Point", "tstathumidsetpoint", "mdi:water-percent", "%"
+ "Humidity Set Point", "tstathumidsetpoint", "mdi:water-percent", UNIT_PERCENTAGE
+ ),
+ WFSensorConfig(
+ "Humidity", "tstatrelativehumidity", "mdi:water-percent", UNIT_PERCENTAGE
),
- WFSensorConfig("Humidity", "tstatrelativehumidity", "mdi:water-percent", "%"),
WFSensorConfig("Compressor Power", "compressorpower", "mdi:flash", "W"),
WFSensorConfig("Fan Power", "fanpower", "mdi:flash", "W"),
WFSensorConfig("Aux Power", "auxpower", "mdi:flash", "W"),
diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py
index b9ca64c0970..0357825cb12 100644
--- a/homeassistant/components/waze_travel_time/sensor.py
+++ b/homeassistant/components/waze_travel_time/sensor.py
@@ -16,6 +16,7 @@ from homeassistant.const import (
CONF_UNIT_SYSTEM_IMPERIAL,
CONF_UNIT_SYSTEM_METRIC,
EVENT_HOMEASSISTANT_START,
+ TIME_MINUTES,
)
from homeassistant.helpers import location
import homeassistant.helpers.config_validation as cv
@@ -167,7 +168,7 @@ class WazeTravelTime(Entity):
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
- return "min"
+ return TIME_MINUTES
@property
def icon(self):
diff --git a/homeassistant/components/weblink/__init__.py b/homeassistant/components/weblink/__init__.py
deleted file mode 100644
index 8a770f916bd..00000000000
--- a/homeassistant/components/weblink/__init__.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""Support for links to external web pages."""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.const import CONF_ICON, CONF_NAME, CONF_URL
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.entity import Entity
-from homeassistant.util import slugify
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_ENTITIES = "entities"
-CONF_RELATIVE_URL_ERROR_MSG = "Invalid relative URL. Absolute path required."
-CONF_RELATIVE_URL_REGEX = r"\A/"
-
-DOMAIN = "weblink"
-
-ENTITIES_SCHEMA = vol.Schema(
- {
- # pylint: disable=no-value-for-parameter
- vol.Required(CONF_URL): vol.Any(
- vol.Match(CONF_RELATIVE_URL_REGEX, msg=CONF_RELATIVE_URL_ERROR_MSG),
- vol.Url(),
- ),
- vol.Required(CONF_NAME): cv.string,
- vol.Optional(CONF_ICON): cv.icon,
- }
-)
-
-CONFIG_SCHEMA = vol.Schema(
- {DOMAIN: vol.Schema({vol.Required(CONF_ENTITIES): [ENTITIES_SCHEMA]})},
- extra=vol.ALLOW_EXTRA,
-)
-
-
-def setup(hass, config):
- """Set up the weblink component."""
- _LOGGER.warning(
- "The weblink integration has been deprecated and is pending for removal "
- "in Home Assistant 0.107.0. Please use this instead: "
- "https://www.home-assistant.io/lovelace/entities/#weblink"
- )
-
- links = config.get(DOMAIN)
-
- for link in links.get(CONF_ENTITIES):
- Link(hass, link.get(CONF_NAME), link.get(CONF_URL), link.get(CONF_ICON))
-
- return True
-
-
-class Link(Entity):
- """Representation of a link."""
-
- def __init__(self, hass, name, url, icon):
- """Initialize the link."""
- self.hass = hass
- self._name = name
- self._url = url
- self._icon = icon
- self.entity_id = DOMAIN + ".%s" % slugify(name)
- self.schedule_update_ha_state()
-
- @property
- def icon(self):
- """Return the icon to use in the frontend, if any."""
- return self._icon
-
- @property
- def name(self):
- """Return the name of the URL."""
- return self._name
-
- @property
- def state(self):
- """Return the URL."""
- return self._url
diff --git a/homeassistant/components/weblink/manifest.json b/homeassistant/components/weblink/manifest.json
deleted file mode 100644
index 28ffa581bb8..00000000000
--- a/homeassistant/components/weblink/manifest.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "domain": "weblink",
- "name": "Weblink",
- "documentation": "https://www.home-assistant.io/integrations/weblink",
- "requirements": [],
- "dependencies": [],
- "codeowners": ["@home-assistant/core"],
- "quality_scale": "internal"
-}
diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py
index b1fa1263a99..61f12fd5f57 100644
--- a/homeassistant/components/websocket_api/const.py
+++ b/homeassistant/components/websocket_api/const.py
@@ -12,10 +12,7 @@ if TYPE_CHECKING:
from .connection import ActiveConnection # noqa
-WebSocketCommandHandler = Callable[
- [HomeAssistant, "ActiveConnection", dict], None
-] # pylint: disable=invalid-name
-
+WebSocketCommandHandler = Callable[[HomeAssistant, "ActiveConnection", dict], None]
DOMAIN = "websocket_api"
URL = "/api/websocket"
diff --git a/homeassistant/components/websocket_api/permissions.py b/homeassistant/components/websocket_api/permissions.py
index bd6013aac0a..8b00981fb04 100644
--- a/homeassistant/components/websocket_api/permissions.py
+++ b/homeassistant/components/websocket_api/permissions.py
@@ -3,7 +3,7 @@
Separate file to avoid circular imports.
"""
from homeassistant.components.frontend import EVENT_PANELS_UPDATED
-from homeassistant.components.lovelace import EVENT_LOVELACE_UPDATED
+from homeassistant.components.lovelace.const import EVENT_LOVELACE_UPDATED
from homeassistant.components.persistent_notification import (
EVENT_PERSISTENT_NOTIFICATIONS_UPDATED,
)
diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py
index dc9da1100f0..7ec5c3dac5e 100644
--- a/homeassistant/components/whois/sensor.py
+++ b/homeassistant/components/whois/sensor.py
@@ -6,7 +6,7 @@ import voluptuous as vol
import whois
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_NAME
+from homeassistant.const import CONF_NAME, TIME_DAYS
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -75,7 +75,7 @@ class WhoisSensor(Entity):
@property
def unit_of_measurement(self):
"""Return the unit of measurement to present the value in."""
- return "days"
+ return TIME_DAYS
@property
def state(self):
diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py
index c0a30a8867f..396ca093eec 100644
--- a/homeassistant/components/wirelesstag/__init__.py
+++ b/homeassistant/components/wirelesstag/__init__.py
@@ -11,6 +11,7 @@ from homeassistant.const import (
ATTR_VOLTAGE,
CONF_PASSWORD,
CONF_USERNAME,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
@@ -284,5 +285,5 @@ class WirelessTagBaseSensor(Entity):
ATTR_VOLTAGE: f"{self._tag.battery_volts:.2f}V",
ATTR_TAG_SIGNAL_STRENGTH: f"{self._tag.signal_strength}dBm",
ATTR_TAG_OUT_OF_RANGE: not self._tag.is_in_range,
- ATTR_TAG_POWER_CONSUMPTION: f"{self._tag.power_consumption:.2f}%",
+ ATTR_TAG_POWER_CONSUMPTION: f"{self._tag.power_consumption:.2f}{UNIT_PERCENTAGE}",
}
diff --git a/homeassistant/components/withings/.translations/lv.json b/homeassistant/components/withings/.translations/lv.json
new file mode 100644
index 00000000000..3f7cf20fdb4
--- /dev/null
+++ b/homeassistant/components/withings/.translations/lv.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "profile": "Profils"
+ },
+ "title": "Lietot\u0101ja profils."
+ }
+ },
+ "title": "Withings"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py
index 856f50ce9ad..781420b347a 100644
--- a/homeassistant/components/withings/const.py
+++ b/homeassistant/components/withings/const.py
@@ -59,12 +59,8 @@ MEAS_TEMP_C = "temperature_c"
MEAS_WEIGHT_KG = "weight_kg"
UOM_BEATS_PER_MINUTE = "bpm"
-UOM_BREATHS_PER_MINUTE = "br/m"
+UOM_BREATHS_PER_MINUTE = f"br/{const.TIME_MINUTES}"
UOM_FREQUENCY = "times"
-UOM_METERS_PER_SECOND = "m/s"
UOM_MMHG = "mmhg"
-UOM_PERCENT = "%"
UOM_LENGTH_M = const.LENGTH_METERS
-UOM_MASS_KG = const.MASS_KILOGRAMS
-UOM_SECONDS = "seconds"
UOM_TEMP_C = const.TEMP_CELSIUS
diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py
index ea570569fa6..7e58beb4419 100644
--- a/homeassistant/components/withings/sensor.py
+++ b/homeassistant/components/withings/sensor.py
@@ -13,6 +13,12 @@ from withings_api.common import (
)
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ MASS_KILOGRAMS,
+ SPEED_METERS_PER_SECOND,
+ TIME_SECONDS,
+ UNIT_PERCENTAGE,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.entity import Entity
@@ -86,35 +92,35 @@ WITHINGS_ATTRIBUTES = [
const.MEAS_WEIGHT_KG,
MeasureType.WEIGHT,
"Weight",
- const.UOM_MASS_KG,
+ MASS_KILOGRAMS,
"mdi:weight-kilogram",
),
WithingsMeasureAttribute(
const.MEAS_FAT_MASS_KG,
MeasureType.FAT_MASS_WEIGHT,
"Fat Mass",
- const.UOM_MASS_KG,
+ MASS_KILOGRAMS,
"mdi:weight-kilogram",
),
WithingsMeasureAttribute(
const.MEAS_FAT_FREE_MASS_KG,
MeasureType.FAT_FREE_MASS,
"Fat Free Mass",
- const.UOM_MASS_KG,
+ MASS_KILOGRAMS,
"mdi:weight-kilogram",
),
WithingsMeasureAttribute(
const.MEAS_MUSCLE_MASS_KG,
MeasureType.MUSCLE_MASS,
"Muscle Mass",
- const.UOM_MASS_KG,
+ MASS_KILOGRAMS,
"mdi:weight-kilogram",
),
WithingsMeasureAttribute(
const.MEAS_BONE_MASS_KG,
MeasureType.BONE_MASS,
"Bone Mass",
- const.UOM_MASS_KG,
+ MASS_KILOGRAMS,
"mdi:weight-kilogram",
),
WithingsMeasureAttribute(
@@ -149,7 +155,7 @@ WITHINGS_ATTRIBUTES = [
const.MEAS_FAT_RATIO_PCT,
MeasureType.FAT_RATIO,
"Fat Ratio",
- const.UOM_PERCENT,
+ UNIT_PERCENTAGE,
None,
),
WithingsMeasureAttribute(
@@ -174,20 +180,20 @@ WITHINGS_ATTRIBUTES = [
"mdi:heart-pulse",
),
WithingsMeasureAttribute(
- const.MEAS_SPO2_PCT, MeasureType.SP02, "SP02", const.UOM_PERCENT, None
+ const.MEAS_SPO2_PCT, MeasureType.SP02, "SP02", UNIT_PERCENTAGE, None
),
WithingsMeasureAttribute(
const.MEAS_HYDRATION,
MeasureType.HYDRATION,
"Hydration",
- const.UOM_PERCENT,
+ UNIT_PERCENTAGE,
"mdi:water",
),
WithingsMeasureAttribute(
const.MEAS_PWV,
MeasureType.PULSE_WAVE_VELOCITY,
"Pulse Wave Velocity",
- const.UOM_METERS_PER_SECOND,
+ SPEED_METERS_PER_SECOND,
None,
),
WithingsSleepStateAttribute(
@@ -197,28 +203,28 @@ WITHINGS_ATTRIBUTES = [
const.MEAS_SLEEP_WAKEUP_DURATION_SECONDS,
GetSleepSummaryField.WAKEUP_DURATION.value,
"Wakeup time",
- const.UOM_SECONDS,
+ TIME_SECONDS,
"mdi:sleep-off",
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_LIGHT_DURATION_SECONDS,
GetSleepSummaryField.LIGHT_SLEEP_DURATION.value,
"Light sleep",
- const.UOM_SECONDS,
+ TIME_SECONDS,
"mdi:sleep",
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_DEEP_DURATION_SECONDS,
GetSleepSummaryField.DEEP_SLEEP_DURATION.value,
"Deep sleep",
- const.UOM_SECONDS,
+ TIME_SECONDS,
"mdi:sleep",
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_REM_DURATION_SECONDS,
GetSleepSummaryField.REM_SLEEP_DURATION.value,
"REM sleep",
- const.UOM_SECONDS,
+ TIME_SECONDS,
"mdi:sleep",
),
WithingsSleepSummaryAttribute(
@@ -232,14 +238,14 @@ WITHINGS_ATTRIBUTES = [
const.MEAS_SLEEP_TOSLEEP_DURATION_SECONDS,
GetSleepSummaryField.DURATION_TO_SLEEP.value,
"Time to sleep",
- const.UOM_SECONDS,
+ TIME_SECONDS,
"mdi:sleep",
),
WithingsSleepSummaryAttribute(
const.MEAS_SLEEP_TOWAKEUP_DURATION_SECONDS,
GetSleepSummaryField.DURATION_TO_WAKEUP.value,
"Time to wakeup",
- const.UOM_SECONDS,
+ TIME_SECONDS,
"mdi:sleep-off",
),
WithingsSleepSummaryAttribute(
diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py
index fa2cae53f52..8d651800a77 100644
--- a/homeassistant/components/worxlandroid/sensor.py
+++ b/homeassistant/components/worxlandroid/sensor.py
@@ -7,7 +7,7 @@ import async_timeout
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_HOST, CONF_PIN, CONF_TIMEOUT
+from homeassistant.const import CONF_HOST, CONF_PIN, CONF_TIMEOUT, UNIT_PERCENTAGE
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -77,7 +77,7 @@ class WorxLandroidSensor(Entity):
def unit_of_measurement(self):
"""Return the unit of measurement of the sensor."""
if self.sensor == "battery":
- return "%"
+ return UNIT_PERCENTAGE
return None
async def async_update(self):
diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py
index 5afa3a3efcf..6ee55aa387f 100644
--- a/homeassistant/components/wsdot/sensor.py
+++ b/homeassistant/components/wsdot/sensor.py
@@ -13,6 +13,7 @@ from homeassistant.const import (
CONF_API_KEY,
CONF_ID,
CONF_NAME,
+ TIME_MINUTES,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -137,7 +138,7 @@ class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor):
@property
def unit_of_measurement(self):
"""Return the unit this state is expressed in."""
- return "min"
+ return TIME_MINUTES
def _parse_wsdot_timestamp(timestamp):
diff --git a/homeassistant/components/wunderground/sensor.py b/homeassistant/components/wunderground/sensor.py
index 44eb10c0e7d..de1e48c9c14 100644
--- a/homeassistant/components/wunderground/sensor.py
+++ b/homeassistant/components/wunderground/sensor.py
@@ -17,12 +17,16 @@ from homeassistant.const import (
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_MONITORED_CONDITIONS,
+ IRRADIATION_WATTS_PER_SQUARE_METER,
LENGTH_FEET,
LENGTH_INCHES,
LENGTH_KILOMETERS,
LENGTH_MILES,
+ SPEED_KILOMETERS_PER_HOUR,
+ SPEED_MILES_PER_HOUR,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
)
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -413,7 +417,7 @@ SENSOR_TYPES = {
"Relative Humidity",
"conditions",
value=lambda wu: int(wu.data["current_observation"]["relative_humidity"][:-1]),
- unit_of_measurement="%",
+ unit_of_measurement=UNIT_PERCENTAGE,
icon="mdi:water-percent",
device_class="humidity",
),
@@ -421,7 +425,10 @@ SENSOR_TYPES = {
"Station ID", "station_id", "mdi:home"
),
"solarradiation": WUCurrentConditionsSensorConfig(
- "Solar Radiation", "solarradiation", "mdi:weather-sunny", "w/m2"
+ "Solar Radiation",
+ "solarradiation",
+ "mdi:weather-sunny",
+ IRRADIATION_WATTS_PER_SQUARE_METER,
),
"temperature_string": WUCurrentConditionsSensorConfig(
"Temperature Summary", "temperature_string", "mdi:thermometer"
@@ -455,16 +462,16 @@ SENSOR_TYPES = {
"Wind Direction", "wind_dir", "mdi:weather-windy"
),
"wind_gust_kph": WUCurrentConditionsSensorConfig(
- "Wind Gust", "wind_gust_kph", "mdi:weather-windy", "kph"
+ "Wind Gust", "wind_gust_kph", "mdi:weather-windy", SPEED_KILOMETERS_PER_HOUR
),
"wind_gust_mph": WUCurrentConditionsSensorConfig(
- "Wind Gust", "wind_gust_mph", "mdi:weather-windy", "mph"
+ "Wind Gust", "wind_gust_mph", "mdi:weather-windy", SPEED_MILES_PER_HOUR
),
"wind_kph": WUCurrentConditionsSensorConfig(
- "Wind Speed", "wind_kph", "mdi:weather-windy", "kph"
+ "Wind Speed", "wind_kph", "mdi:weather-windy", SPEED_KILOMETERS_PER_HOUR
),
"wind_mph": WUCurrentConditionsSensorConfig(
- "Wind Speed", "wind_mph", "mdi:weather-windy", "mph"
+ "Wind Speed", "wind_mph", "mdi:weather-windy", SPEED_MILES_PER_HOUR
),
"wind_string": WUCurrentConditionsSensorConfig(
"Wind Summary", "wind_string", "mdi:weather-windy"
@@ -738,52 +745,132 @@ SENSOR_TYPES = {
device_class="temperature",
),
"wind_gust_1d_kph": WUDailySimpleForecastSensorConfig(
- "Max. Wind Today", 0, "maxwind", "kph", "kph", "mdi:weather-windy"
+ "Max. Wind Today",
+ 0,
+ "maxwind",
+ SPEED_KILOMETERS_PER_HOUR,
+ SPEED_KILOMETERS_PER_HOUR,
+ "mdi:weather-windy",
),
"wind_gust_2d_kph": WUDailySimpleForecastSensorConfig(
- "Max. Wind Tomorrow", 1, "maxwind", "kph", "kph", "mdi:weather-windy"
+ "Max. Wind Tomorrow",
+ 1,
+ "maxwind",
+ SPEED_KILOMETERS_PER_HOUR,
+ SPEED_KILOMETERS_PER_HOUR,
+ "mdi:weather-windy",
),
"wind_gust_3d_kph": WUDailySimpleForecastSensorConfig(
- "Max. Wind in 3 Days", 2, "maxwind", "kph", "kph", "mdi:weather-windy"
+ "Max. Wind in 3 Days",
+ 2,
+ "maxwind",
+ SPEED_KILOMETERS_PER_HOUR,
+ SPEED_KILOMETERS_PER_HOUR,
+ "mdi:weather-windy",
),
"wind_gust_4d_kph": WUDailySimpleForecastSensorConfig(
- "Max. Wind in 4 Days", 3, "maxwind", "kph", "kph", "mdi:weather-windy"
+ "Max. Wind in 4 Days",
+ 3,
+ "maxwind",
+ SPEED_KILOMETERS_PER_HOUR,
+ SPEED_KILOMETERS_PER_HOUR,
+ "mdi:weather-windy",
),
"wind_gust_1d_mph": WUDailySimpleForecastSensorConfig(
- "Max. Wind Today", 0, "maxwind", "mph", "mph", "mdi:weather-windy"
+ "Max. Wind Today",
+ 0,
+ "maxwind",
+ SPEED_MILES_PER_HOUR,
+ SPEED_MILES_PER_HOUR,
+ "mdi:weather-windy",
),
"wind_gust_2d_mph": WUDailySimpleForecastSensorConfig(
- "Max. Wind Tomorrow", 1, "maxwind", "mph", "mph", "mdi:weather-windy"
+ "Max. Wind Tomorrow",
+ 1,
+ "maxwind",
+ SPEED_MILES_PER_HOUR,
+ SPEED_MILES_PER_HOUR,
+ "mdi:weather-windy",
),
"wind_gust_3d_mph": WUDailySimpleForecastSensorConfig(
- "Max. Wind in 3 Days", 2, "maxwind", "mph", "mph", "mdi:weather-windy"
+ "Max. Wind in 3 Days",
+ 2,
+ "maxwind",
+ SPEED_MILES_PER_HOUR,
+ SPEED_MILES_PER_HOUR,
+ "mdi:weather-windy",
),
"wind_gust_4d_mph": WUDailySimpleForecastSensorConfig(
- "Max. Wind in 4 Days", 3, "maxwind", "mph", "mph", "mdi:weather-windy"
+ "Max. Wind in 4 Days",
+ 3,
+ "maxwind",
+ SPEED_MILES_PER_HOUR,
+ SPEED_MILES_PER_HOUR,
+ "mdi:weather-windy",
),
"wind_1d_kph": WUDailySimpleForecastSensorConfig(
- "Avg. Wind Today", 0, "avewind", "kph", "kph", "mdi:weather-windy"
+ "Avg. Wind Today",
+ 0,
+ "avewind",
+ SPEED_KILOMETERS_PER_HOUR,
+ SPEED_KILOMETERS_PER_HOUR,
+ "mdi:weather-windy",
),
"wind_2d_kph": WUDailySimpleForecastSensorConfig(
- "Avg. Wind Tomorrow", 1, "avewind", "kph", "kph", "mdi:weather-windy"
+ "Avg. Wind Tomorrow",
+ 1,
+ "avewind",
+ SPEED_KILOMETERS_PER_HOUR,
+ SPEED_KILOMETERS_PER_HOUR,
+ "mdi:weather-windy",
),
"wind_3d_kph": WUDailySimpleForecastSensorConfig(
- "Avg. Wind in 3 Days", 2, "avewind", "kph", "kph", "mdi:weather-windy"
+ "Avg. Wind in 3 Days",
+ 2,
+ "avewind",
+ SPEED_KILOMETERS_PER_HOUR,
+ SPEED_KILOMETERS_PER_HOUR,
+ "mdi:weather-windy",
),
"wind_4d_kph": WUDailySimpleForecastSensorConfig(
- "Avg. Wind in 4 Days", 3, "avewind", "kph", "kph", "mdi:weather-windy"
+ "Avg. Wind in 4 Days",
+ 3,
+ "avewind",
+ SPEED_KILOMETERS_PER_HOUR,
+ SPEED_KILOMETERS_PER_HOUR,
+ "mdi:weather-windy",
),
"wind_1d_mph": WUDailySimpleForecastSensorConfig(
- "Avg. Wind Today", 0, "avewind", "mph", "mph", "mdi:weather-windy"
+ "Avg. Wind Today",
+ 0,
+ "avewind",
+ SPEED_MILES_PER_HOUR,
+ SPEED_MILES_PER_HOUR,
+ "mdi:weather-windy",
),
"wind_2d_mph": WUDailySimpleForecastSensorConfig(
- "Avg. Wind Tomorrow", 1, "avewind", "mph", "mph", "mdi:weather-windy"
+ "Avg. Wind Tomorrow",
+ 1,
+ "avewind",
+ SPEED_MILES_PER_HOUR,
+ SPEED_MILES_PER_HOUR,
+ "mdi:weather-windy",
),
"wind_3d_mph": WUDailySimpleForecastSensorConfig(
- "Avg. Wind in 3 Days", 2, "avewind", "mph", "mph", "mdi:weather-windy"
+ "Avg. Wind in 3 Days",
+ 2,
+ "avewind",
+ SPEED_MILES_PER_HOUR,
+ SPEED_MILES_PER_HOUR,
+ "mdi:weather-windy",
),
"wind_4d_mph": WUDailySimpleForecastSensorConfig(
- "Avg. Wind in 4 Days", 3, "avewind", "mph", "mph", "mdi:weather-windy"
+ "Avg. Wind in 4 Days",
+ 3,
+ "avewind",
+ SPEED_MILES_PER_HOUR,
+ SPEED_MILES_PER_HOUR,
+ "mdi:weather-windy",
),
"precip_1d_mm": WUDailySimpleForecastSensorConfig(
"Precipitation Intensity Today", 0, "qpf_allday", "mm", "mm", "mdi:umbrella"
@@ -830,16 +917,36 @@ SENSOR_TYPES = {
"mdi:umbrella",
),
"precip_1d": WUDailySimpleForecastSensorConfig(
- "Precipitation Probability Today", 0, "pop", None, "%", "mdi:umbrella"
+ "Precipitation Probability Today",
+ 0,
+ "pop",
+ None,
+ UNIT_PERCENTAGE,
+ "mdi:umbrella",
),
"precip_2d": WUDailySimpleForecastSensorConfig(
- "Precipitation Probability Tomorrow", 1, "pop", None, "%", "mdi:umbrella"
+ "Precipitation Probability Tomorrow",
+ 1,
+ "pop",
+ None,
+ UNIT_PERCENTAGE,
+ "mdi:umbrella",
),
"precip_3d": WUDailySimpleForecastSensorConfig(
- "Precipitation Probability in 3 Days", 2, "pop", None, "%", "mdi:umbrella"
+ "Precipitation Probability in 3 Days",
+ 2,
+ "pop",
+ None,
+ UNIT_PERCENTAGE,
+ "mdi:umbrella",
),
"precip_4d": WUDailySimpleForecastSensorConfig(
- "Precipitation Probability in 4 Days", 3, "pop", None, "%", "mdi:umbrella"
+ "Precipitation Probability in 4 Days",
+ 3,
+ "pop",
+ None,
+ UNIT_PERCENTAGE,
+ "mdi:umbrella",
),
}
diff --git a/homeassistant/components/wwlln/.translations/en.json b/homeassistant/components/wwlln/.translations/en.json
index 4200c4b4378..48896cc8682 100644
--- a/homeassistant/components/wwlln/.translations/en.json
+++ b/homeassistant/components/wwlln/.translations/en.json
@@ -1,7 +1,7 @@
{
"config": {
- "error": {
- "identifier_exists": "Location already registered"
+ "abort": {
+ "already_configured": "This location is already registered."
},
"step": {
"user": {
diff --git a/homeassistant/components/wwlln/__init__.py b/homeassistant/components/wwlln/__init__.py
index 412efc904db..d83e19bd391 100644
--- a/homeassistant/components/wwlln/__init__.py
+++ b/homeassistant/components/wwlln/__init__.py
@@ -5,17 +5,9 @@ from aiowwlln import Client
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
-from homeassistant.const import (
- CONF_LATITUDE,
- CONF_LONGITUDE,
- CONF_RADIUS,
- CONF_UNIT_SYSTEM,
- CONF_UNIT_SYSTEM_IMPERIAL,
- CONF_UNIT_SYSTEM_METRIC,
-)
+from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
from homeassistant.helpers import aiohttp_client, config_validation as cv
-from .config_flow import configured_instances
from .const import CONF_WINDOW, DATA_CLIENT, DEFAULT_RADIUS, DEFAULT_WINDOW, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -28,7 +20,10 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_LONGITUDE): cv.longitude,
vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): cv.positive_int,
vol.Optional(CONF_WINDOW, default=DEFAULT_WINDOW): vol.All(
- cv.time_period, cv.positive_timedelta
+ cv.time_period,
+ cv.positive_timedelta,
+ lambda value: value.total_seconds(),
+ vol.Range(min=DEFAULT_WINDOW.total_seconds()),
),
}
)
@@ -44,36 +39,9 @@ async def async_setup(hass, config):
conf = config[DOMAIN]
- latitude = conf.get(CONF_LATITUDE, hass.config.latitude)
- longitude = conf.get(CONF_LONGITUDE, hass.config.longitude)
-
- identifier = f"{latitude}, {longitude}"
- if identifier in configured_instances(hass):
- return True
-
- if conf[CONF_WINDOW] < DEFAULT_WINDOW:
- _LOGGER.warning(
- "Setting a window smaller than %s seconds may cause Home Assistant \
- to miss events",
- DEFAULT_WINDOW.total_seconds(),
- )
-
- if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL:
- unit_system = CONF_UNIT_SYSTEM_IMPERIAL
- else:
- unit_system = CONF_UNIT_SYSTEM_METRIC
-
hass.async_create_task(
hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data={
- CONF_LATITUDE: latitude,
- CONF_LONGITUDE: longitude,
- CONF_RADIUS: conf[CONF_RADIUS],
- CONF_WINDOW: conf[CONF_WINDOW],
- CONF_UNIT_SYSTEM: unit_system,
- },
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
)
)
@@ -82,6 +50,15 @@ async def async_setup(hass, config):
async def async_setup_entry(hass, config_entry):
"""Set up the WWLLN as config entry."""
+ if not config_entry.unique_id:
+ hass.config_entries.async_update_entry(
+ config_entry,
+ unique_id=(
+ f"{config_entry.data[CONF_LATITUDE]}, "
+ f"{config_entry.data[CONF_LONGITUDE]}"
+ ),
+ )
+
hass.data[DOMAIN] = {}
hass.data[DOMAIN][DATA_CLIENT] = {}
diff --git a/homeassistant/components/wwlln/config_flow.py b/homeassistant/components/wwlln/config_flow.py
index f9cd022f255..4ec7c2a9a0c 100644
--- a/homeassistant/components/wwlln/config_flow.py
+++ b/homeassistant/components/wwlln/config_flow.py
@@ -2,39 +2,27 @@
import voluptuous as vol
from homeassistant import config_entries
-from homeassistant.const import (
- CONF_LATITUDE,
- CONF_LONGITUDE,
- CONF_RADIUS,
- CONF_UNIT_SYSTEM,
- CONF_UNIT_SYSTEM_IMPERIAL,
- CONF_UNIT_SYSTEM_METRIC,
-)
-from homeassistant.core import callback
+from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
from homeassistant.helpers import config_validation as cv
-from .const import CONF_WINDOW, DEFAULT_RADIUS, DEFAULT_WINDOW, DOMAIN
+from .const import ( # pylint: disable=unused-import
+ CONF_WINDOW,
+ DEFAULT_RADIUS,
+ DEFAULT_WINDOW,
+ DOMAIN,
+)
-@callback
-def configured_instances(hass):
- """Return a set of configured WWLLN instances."""
- return set(
- "{0}, {1}".format(entry.data[CONF_LATITUDE], entry.data[CONF_LONGITUDE])
- for entry in hass.config_entries.async_entries(DOMAIN)
- )
-
-
-@config_entries.HANDLERS.register(DOMAIN)
-class WWLLNFlowHandler(config_entries.ConfigFlow):
+class WWLLNFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a WWLLN config flow."""
VERSION = 2
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
- async def _show_form(self, errors=None):
- """Show the form to the user."""
- data_schema = vol.Schema(
+ @property
+ def data_schema(self):
+ """Return the data schema for the user form."""
+ return vol.Schema(
{
vol.Optional(
CONF_LATITUDE, default=self.hass.config.latitude
@@ -46,8 +34,10 @@ class WWLLNFlowHandler(config_entries.ConfigFlow):
}
)
+ async def _show_form(self, errors=None):
+ """Show the form to the user."""
return self.async_show_form(
- step_id="user", data_schema=data_schema, errors=errors or {}
+ step_id="user", data_schema=self.data_schema, errors=errors or {}
)
async def async_step_import(self, import_config):
@@ -59,25 +49,22 @@ class WWLLNFlowHandler(config_entries.ConfigFlow):
if not user_input:
return await self._show_form()
- identifier = "{0}, {1}".format(
- user_input[CONF_LATITUDE], user_input[CONF_LONGITUDE]
+ latitude = user_input.get(CONF_LATITUDE, self.hass.config.latitude)
+ longitude = user_input.get(CONF_LONGITUDE, self.hass.config.longitude)
+
+ identifier = f"{latitude}, {longitude}"
+
+ await self.async_set_unique_id(identifier)
+ self._abort_if_unique_id_configured()
+
+ return self.async_create_entry(
+ title=identifier,
+ data={
+ CONF_LATITUDE: latitude,
+ CONF_LONGITUDE: longitude,
+ CONF_RADIUS: user_input.get(CONF_RADIUS, DEFAULT_RADIUS),
+ CONF_WINDOW: user_input.get(
+ CONF_WINDOW, DEFAULT_WINDOW.total_seconds()
+ ),
+ },
)
- if identifier in configured_instances(self.hass):
- return await self._show_form({"base": "identifier_exists"})
-
- if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL:
- user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_IMPERIAL
- else:
- user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_METRIC
-
- # When importing from `configuration.yaml`, we give the user
- # flexibility by allowing the `window` parameter to be any type
- # of time period. This will always return a timedelta; unfortunately,
- # timedeltas aren't JSON-serializable, so we can't store them in a
- # config entry as-is; instead, we save the total seconds as an int:
- if CONF_WINDOW in user_input:
- user_input[CONF_WINDOW] = user_input[CONF_WINDOW].total_seconds()
- else:
- user_input[CONF_WINDOW] = DEFAULT_WINDOW.total_seconds()
-
- return self.async_create_entry(title=identifier, data=user_input)
diff --git a/homeassistant/components/wwlln/geo_location.py b/homeassistant/components/wwlln/geo_location.py
index e1ca47664d5..ed4d4fcd6b8 100644
--- a/homeassistant/components/wwlln/geo_location.py
+++ b/homeassistant/components/wwlln/geo_location.py
@@ -10,7 +10,6 @@ from homeassistant.const import (
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_RADIUS,
- CONF_UNIT_SYSTEM,
CONF_UNIT_SYSTEM_IMPERIAL,
LENGTH_KILOMETERS,
LENGTH_MILES,
@@ -49,7 +48,6 @@ async def async_setup_entry(hass, entry, async_add_entities):
entry.data[CONF_LONGITUDE],
entry.data[CONF_RADIUS],
entry.data[CONF_WINDOW],
- entry.data[CONF_UNIT_SYSTEM],
)
await manager.async_init()
@@ -66,7 +64,6 @@ class WWLLNEventManager:
longitude,
radius,
window_seconds,
- unit_system,
):
"""Initialize."""
self._async_add_entities = async_add_entities
@@ -79,8 +76,7 @@ class WWLLNEventManager:
self._strikes = {}
self._window = timedelta(seconds=window_seconds)
- self._unit_system = unit_system
- if unit_system == CONF_UNIT_SYSTEM_IMPERIAL:
+ if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL:
self._unit = LENGTH_MILES
else:
self._unit = LENGTH_KILOMETERS
@@ -130,7 +126,7 @@ class WWLLNEventManager:
self._latitude,
self._longitude,
self._radius,
- unit=self._unit_system,
+ unit=self._hass.config.units.name,
window=self._window,
)
except WWLLNError as err:
diff --git a/homeassistant/components/wwlln/strings.json b/homeassistant/components/wwlln/strings.json
index c0d768a010c..0ab731eaf50 100644
--- a/homeassistant/components/wwlln/strings.json
+++ b/homeassistant/components/wwlln/strings.json
@@ -11,8 +11,8 @@
}
}
},
- "error": {
- "identifier_exists": "Location already registered"
+ "abort": {
+ "already_configured": "This location is already registered."
}
}
}
diff --git a/homeassistant/components/xfinity/device_tracker.py b/homeassistant/components/xfinity/device_tracker.py
index 20e13682979..832c8bb1d5d 100644
--- a/homeassistant/components/xfinity/device_tracker.py
+++ b/homeassistant/components/xfinity/device_tracker.py
@@ -24,6 +24,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
def get_scanner(hass, config):
"""Validate the configuration and return an Xfinity Gateway scanner."""
+ _LOGGER.warning(
+ "The Xfinity Gateway has been deprecated and will be removed from "
+ "Home Assistant in version 0.109. Please remove it from your "
+ "configuration. "
+ )
gateway = XfinityGateway(config[DOMAIN][CONF_HOST])
scanner = None
diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py
index 5ad29af0aaf..d793f920349 100644
--- a/homeassistant/components/xiaomi_aqara/sensor.py
+++ b/homeassistant/components/xiaomi_aqara/sensor.py
@@ -7,6 +7,7 @@ from homeassistant.const import (
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from . import PY_XIAOMI_GATEWAY, XiaomiDevice
@@ -15,7 +16,7 @@ _LOGGER = logging.getLogger(__name__)
SENSOR_TYPES = {
"temperature": [TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE],
- "humidity": ["%", None, DEVICE_CLASS_HUMIDITY],
+ "humidity": [UNIT_PERCENTAGE, None, DEVICE_CLASS_HUMIDITY],
"illumination": ["lm", None, DEVICE_CLASS_ILLUMINANCE],
"lux": ["lx", None, DEVICE_CLASS_ILLUMINANCE],
"pressure": ["hPa", None, DEVICE_CLASS_PRESSURE],
diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py
index 110ca7cff49..93aeb0d28b7 100644
--- a/homeassistant/components/xiaomi_miio/air_quality.py
+++ b/homeassistant/components/xiaomi_miio/air_quality.py
@@ -5,7 +5,12 @@ from miio import AirQualityMonitor, Device, DeviceException
import voluptuous as vol
from homeassistant.components.air_quality import PLATFORM_SCHEMA, AirQualityEntity
-from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN
+from homeassistant.const import (
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ CONF_HOST,
+ CONF_NAME,
+ CONF_TOKEN,
+)
from homeassistant.exceptions import NoEntitySpecifiedError, PlatformNotReady
import homeassistant.helpers.config_validation as cv
@@ -88,7 +93,7 @@ class AirMonitorB1(AirQualityEntity):
self._device = device
self._unique_id = unique_id
self._icon = "mdi:cloud"
- self._unit_of_measurement = "μg/m3"
+ self._unit_of_measurement = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
self._available = None
self._air_quality_index = None
self._carbon_dioxide = None
diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py
index 54db4882e74..be029715cce 100644
--- a/homeassistant/components/yandex_transport/sensor.py
+++ b/homeassistant/components/yandex_transport/sensor.py
@@ -65,7 +65,6 @@ class DiscoverMoscowYandexTransport(Entity):
try:
yandex_reply = self.requester.get_stop_info(self._stop_id)
data = yandex_reply["data"]
- stop_metadata = data["properties"]["StopMetaData"]
except KeyError as key_error:
_LOGGER.warning(
"Exception KeyError was captured, missing key is %s. Yandex returned: %s",
@@ -74,9 +73,8 @@ class DiscoverMoscowYandexTransport(Entity):
)
self.requester.set_new_session()
data = self.requester.get_stop_info(self._stop_id)["data"]
- stop_metadata = data["properties"]["StopMetaData"]
- stop_name = data["properties"]["name"]
- transport_list = stop_metadata["Transport"]
+ stop_name = data["name"]
+ transport_list = data["transports"]
for transport in transport_list:
route = transport["name"]
for thread in transport["threads"]:
diff --git a/homeassistant/components/yr/sensor.py b/homeassistant/components/yr/sensor.py
index c9392561fc8..c6aaeea7ac9 100644
--- a/homeassistant/components/yr/sensor.py
+++ b/homeassistant/components/yr/sensor.py
@@ -21,7 +21,9 @@ from homeassistant.const import (
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
PRESSURE_HPA,
+ SPEED_METERS_PER_SECOND,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
@@ -41,16 +43,16 @@ SENSOR_TYPES = {
"symbol": ["Symbol", None, None],
"precipitation": ["Precipitation", "mm", None],
"temperature": ["Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE],
- "windSpeed": ["Wind speed", "m/s", None],
- "windGust": ["Wind gust", "m/s", None],
+ "windSpeed": ["Wind speed", SPEED_METERS_PER_SECOND, None],
+ "windGust": ["Wind gust", SPEED_METERS_PER_SECOND, None],
"pressure": ["Pressure", PRESSURE_HPA, DEVICE_CLASS_PRESSURE],
"windDirection": ["Wind direction", "°", None],
- "humidity": ["Humidity", "%", DEVICE_CLASS_HUMIDITY],
- "fog": ["Fog", "%", None],
- "cloudiness": ["Cloudiness", "%", None],
- "lowClouds": ["Low clouds", "%", None],
- "mediumClouds": ["Medium clouds", "%", None],
- "highClouds": ["High clouds", "%", None],
+ "humidity": ["Humidity", UNIT_PERCENTAGE, DEVICE_CLASS_HUMIDITY],
+ "fog": ["Fog", UNIT_PERCENTAGE, None],
+ "cloudiness": ["Cloudiness", UNIT_PERCENTAGE, None],
+ "lowClouds": ["Low clouds", UNIT_PERCENTAGE, None],
+ "mediumClouds": ["Medium clouds", UNIT_PERCENTAGE, None],
+ "highClouds": ["High clouds", UNIT_PERCENTAGE, None],
"dewpointTemperature": [
"Dewpoint temperature",
TEMP_CELSIUS,
diff --git a/homeassistant/components/yweather/sensor.py b/homeassistant/components/yweather/sensor.py
index c7f752a8836..db21c430c4d 100644
--- a/homeassistant/components/yweather/sensor.py
+++ b/homeassistant/components/yweather/sensor.py
@@ -16,6 +16,7 @@ from homeassistant.const import (
CONF_MONITORED_CONDITIONS,
CONF_NAME,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -40,7 +41,7 @@ SENSOR_TYPES = {
"temp_min": ["Temperature min", "temperature"],
"temp_max": ["Temperature max", "temperature"],
"wind_speed": ["Wind speed", "speed"],
- "humidity": ["Humidity", "%"],
+ "humidity": ["Humidity", UNIT_PERCENTAGE],
"pressure": ["Pressure", "pressure"],
"visibility": ["Visibility", "distance"],
}
diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py
index 44c216eb1be..a5eb90df218 100644
--- a/homeassistant/components/zamg/sensor.py
+++ b/homeassistant/components/zamg/sensor.py
@@ -17,6 +17,8 @@ from homeassistant.const import (
CONF_LONGITUDE,
CONF_MONITORED_CONDITIONS,
CONF_NAME,
+ SPEED_KILOMETERS_PER_HOUR,
+ UNIT_PERCENTAGE,
__version__,
)
import homeassistant.helpers.config_validation as cv
@@ -38,12 +40,22 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10)
SENSOR_TYPES = {
"pressure": ("Pressure", "hPa", "LDstat hPa", float),
"pressure_sealevel": ("Pressure at Sea Level", "hPa", "LDred hPa", float),
- "humidity": ("Humidity", "%", "RF %", int),
- "wind_speed": ("Wind Speed", "km/h", "WG km/h", float),
+ "humidity": ("Humidity", UNIT_PERCENTAGE, "RF %", int),
+ "wind_speed": (
+ "Wind Speed",
+ SPEED_KILOMETERS_PER_HOUR,
+ f"WG {SPEED_KILOMETERS_PER_HOUR}",
+ float,
+ ),
"wind_bearing": ("Wind Bearing", "°", "WR °", int),
- "wind_max_speed": ("Top Wind Speed", "km/h", "WSG km/h", float),
+ "wind_max_speed": (
+ "Top Wind Speed",
+ SPEED_KILOMETERS_PER_HOUR,
+ f"WSG {SPEED_KILOMETERS_PER_HOUR}",
+ float,
+ ),
"wind_max_bearing": ("Top Wind Bearing", "°", "WSR °", int),
- "sun_last_hour": ("Sun Last Hour", "%", "SO %", int),
+ "sun_last_hour": ("Sun Last Hour", UNIT_PERCENTAGE, f"SO {UNIT_PERCENTAGE}", int),
"temperature": ("Temperature", "°C", "T °C", float),
"precipitation": ("Precipitation", "l/m²", "N l/m²", float),
"dewpoint": ("Dew Point", "°C", "TP °C", float),
diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json
index e038b9c0da1..b0808d83d68 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.4"],
+ "requirements": ["zeroconf==0.24.5"],
"dependencies": ["api"],
"codeowners": ["@robbiet480", "@Kane610"],
"quality_scale": "internal"
diff --git a/homeassistant/components/zha/.translations/en.json b/homeassistant/components/zha/.translations/en.json
index d8e8955a935..500083a7e4e 100644
--- a/homeassistant/components/zha/.translations/en.json
+++ b/homeassistant/components/zha/.translations/en.json
@@ -54,6 +54,14 @@
"device_shaken": "Device shaken",
"device_slid": "Device slid \"{subtype}\"",
"device_tilted": "Device tilted",
+ "remote_button_alt_double_press": "\"{subtype}\" button double clicked (Alternate mode)",
+ "remote_button_alt_long_press": "\"{subtype}\" button continuously pressed (Alternate mode)",
+ "remote_button_alt_long_release": "\"{subtype}\" button released after long press (Alternate mode)",
+ "remote_button_alt_quadruple_press": "\"{subtype}\" button quadruple clicked (Alternate mode)",
+ "remote_button_alt_quintuple_press": "\"{subtype}\" button quintuple clicked (Alternate mode)",
+ "remote_button_alt_short_press": "\"{subtype}\" button pressed (Alternate mode)",
+ "remote_button_alt_short_release": "\"{subtype}\" button released (Alternate mode)",
+ "remote_button_alt_triple_press": "\"{subtype}\" button triple clicked (Alternate mode)",
"remote_button_double_press": "\"{subtype}\" button double clicked",
"remote_button_long_press": "\"{subtype}\" button continuously pressed",
"remote_button_long_release": "\"{subtype}\" button released after long press",
diff --git a/homeassistant/components/zha/.translations/es.json b/homeassistant/components/zha/.translations/es.json
index fb2271b260a..2bf817daf63 100644
--- a/homeassistant/components/zha/.translations/es.json
+++ b/homeassistant/components/zha/.translations/es.json
@@ -54,14 +54,22 @@
"device_shaken": "Dispositivo agitado",
"device_slid": "Dispositivo deslizado \" {subtype} \"",
"device_tilted": "Dispositivo inclinado",
- "remote_button_double_press": "\"{subtype}\" bot\u00f3n de doble clic",
+ "remote_button_alt_double_press": "Bot\u00f3n \"{subtype}\" doble pulsaci\u00f3n (modo Alternativo)",
+ "remote_button_alt_long_press": "Bot\u00f3n \"{subtype}\" pulsado continuamente (modo Alternativo)",
+ "remote_button_alt_long_release": "Bot\u00f3n \"{subtype}\" soltado despu\u00e9s de una pulsaci\u00f3n larga (modo Alternativo)",
+ "remote_button_alt_quadruple_press": "Bot\u00f3n \"{subtype}\" cu\u00e1druple pulsaci\u00f3n (modo Alternativo)",
+ "remote_button_alt_quintuple_press": "Bot\u00f3n \"{subtype}\" qu\u00edntuple pulsaci\u00f3n (modo Alternativo)",
+ "remote_button_alt_short_press": "Bot\u00f3n \"{subtype}\" pulsado (modo Alternativo)",
+ "remote_button_alt_short_release": "Bot\u00f3n \"{subtype}\" soltado (modo Alternativo)",
+ "remote_button_alt_triple_press": "Bot\u00f3n \"{subtype}\" triple pulsaci\u00f3n (modo Alternativo)",
+ "remote_button_double_press": "Bot\u00f3n \"{subtype}\" doble pulsaci\u00f3n",
"remote_button_long_press": "Bot\u00f3n \"{subtype}\" pulsado continuamente",
- "remote_button_long_release": "Bot\u00f3n \"{subtype}\" liberado despu\u00e9s de una pulsaci\u00f3n prolongada",
- "remote_button_quadruple_press": "\"{subtype}\" bot\u00f3n cu\u00e1druple pulsado",
- "remote_button_quintuple_press": "\"{subtype}\" bot\u00f3n qu\u00edntuple pulsado",
+ "remote_button_long_release": "Bot\u00f3n \"{subtype}\" soltado despu\u00e9s de una pulsaci\u00f3n larga",
+ "remote_button_quadruple_press": "Bot\u00f3n \"{subtype}\" cu\u00e1druple pulsaci\u00f3n",
+ "remote_button_quintuple_press": "Bot\u00f3n \"{subtype}\" qu\u00edntuple pulsaci\u00f3n",
"remote_button_short_press": "Bot\u00f3n \"{subtype}\" pulsado",
- "remote_button_short_release": "Bot\u00f3n \"{subtype}\" liberado",
- "remote_button_triple_press": "\"{subtype}\" bot\u00f3n de triple clic"
+ "remote_button_short_release": "Bot\u00f3n \"{subtype}\" soltado",
+ "remote_button_triple_press": "Bot\u00f3n \"{subtype}\" triple pulsaci\u00f3n"
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/lb.json b/homeassistant/components/zha/.translations/lb.json
index a289e05e667..c4c65bf2037 100644
--- a/homeassistant/components/zha/.translations/lb.json
+++ b/homeassistant/components/zha/.translations/lb.json
@@ -54,6 +54,14 @@
"device_shaken": "Apparat ger\u00ebselt",
"device_slid": "Apparat gerutscht \"{subtype}\"",
"device_tilted": "Apparat ass gekippt",
+ "remote_button_alt_double_press": "\"{subtype}\" Kn\u00e4ppche zwee mol gedr\u00e9ckt (Alternative Modus)",
+ "remote_button_alt_long_press": "\"{subtype}\" Kn\u00e4ppche permanent gedr\u00e9ckt (Alternative Modus)",
+ "remote_button_alt_long_release": "\"{subtype}\" Kn\u00e4ppche no laangem unhalen lassgelooss (Alternative Modus)",
+ "remote_button_alt_quadruple_press": "\"{subtype}\" Kn\u00e4ppche v\u00e9ier mol gedr\u00e9ckt (Alternative Modus)",
+ "remote_button_alt_quintuple_press": "\"{subtype}\" Kn\u00e4ppche f\u00ebnnef mol gedr\u00e9ckt (Alternative Modus)",
+ "remote_button_alt_short_press": "\"{subtype}\" Kn\u00e4ppche gedr\u00e9ckt (Alternative Modus)",
+ "remote_button_alt_short_release": "\"{subtype}\" Kn\u00e4ppche lassgelooss (Alternative Modus)",
+ "remote_button_alt_triple_press": "\"{subtype}\" Kn\u00e4ppche dr\u00e4imol gedr\u00e9ckt (Alternative Modus)",
"remote_button_double_press": "\"{subtype}\" Kn\u00e4ppche zwee mol gedr\u00e9ckt",
"remote_button_long_press": "\"{subtype}\" Kn\u00e4ppche permanent gedr\u00e9ckt",
"remote_button_long_release": "\"{subtype}\" Kn\u00e4ppche no laangem unhalen lassgelooss",
diff --git a/homeassistant/components/zha/.translations/no.json b/homeassistant/components/zha/.translations/no.json
index a70f5ad1c33..a08761ac4b6 100644
--- a/homeassistant/components/zha/.translations/no.json
+++ b/homeassistant/components/zha/.translations/no.json
@@ -54,6 +54,14 @@
"device_shaken": "Enhet er ristet",
"device_slid": "Enheten skled \"{subtype}\"",
"device_tilted": "Enheten skr\u00e5stilt",
+ "remote_button_alt_double_press": "\" {subtype} \" -knapp dobbeltklikket (alternativ modus)",
+ "remote_button_alt_long_press": "\" {subtype} \" -knappen trykkes kontinuerlig (alternativ modus)",
+ "remote_button_alt_long_release": "\" {subtype} \" -knapp sluppet etter langt trykk (Alternativ modus)",
+ "remote_button_alt_quadruple_press": "\"{subtype}\" knapp firedoblet klikket (alternativ modus)",
+ "remote_button_alt_quintuple_press": "\"{subtype}\" knapp femdobblet klikket (alternativ modus)",
+ "remote_button_alt_short_press": "\" {subtype} \" -knappen trykket p\u00e5 (alternativ modus)",
+ "remote_button_alt_short_release": "\" {subtype} \" -knapp utgitt (alternativ modus)",
+ "remote_button_alt_triple_press": "\" {subtype} \" -knapp tredobbeltklikket (alternativ modus)",
"remote_button_double_press": "\"{subtype}\"-knappen ble dobbeltklikket",
"remote_button_long_press": "\"{subtype}\"-knappen ble holdt inne",
"remote_button_long_release": "\"{subtype}\"-knappen sluppet etter langt trykk",
diff --git a/homeassistant/components/zha/.translations/ru.json b/homeassistant/components/zha/.translations/ru.json
index 38b0aa8359c..c5f38d00d69 100644
--- a/homeassistant/components/zha/.translations/ru.json
+++ b/homeassistant/components/zha/.translations/ru.json
@@ -54,6 +54,14 @@
"device_shaken": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0432\u0441\u0442\u0440\u044f\u0445\u043d\u0443\u043b\u0438",
"device_slid": "\u0421\u0434\u0432\u0438\u0433 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \"{subtype}\"",
"device_tilted": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0430\u043a\u043b\u043e\u043d\u0438\u043b\u0438",
+ "remote_button_alt_double_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0434\u0432\u0430 \u0440\u0430\u0437\u0430 (\u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c)",
+ "remote_button_alt_long_press": "\"{subtype}\" \u043d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u043e \u043d\u0430\u0436\u0430\u0442\u0430 (\u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c)",
+ "remote_button_alt_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 (\u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c)",
+ "remote_button_alt_quadruple_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0447\u0435\u0442\u044b\u0440\u0435 \u0440\u0430\u0437\u0430 (\u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c)",
+ "remote_button_alt_quintuple_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u043f\u044f\u0442\u044c \u0440\u0430\u0437 (\u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c)",
+ "remote_button_alt_short_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 (\u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c)",
+ "remote_button_alt_short_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 (\u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c)",
+ "remote_button_alt_triple_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0442\u0440\u0438 \u0440\u0430\u0437\u0430 (\u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c)",
"remote_button_double_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0434\u0432\u0430 \u0440\u0430\u0437\u0430",
"remote_button_long_press": "\"{subtype}\" \u043d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u043e \u043d\u0430\u0436\u0430\u0442\u0430",
"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",
diff --git a/homeassistant/components/zha/.translations/zh-Hant.json b/homeassistant/components/zha/.translations/zh-Hant.json
index d7f421c7e84..9547e7b5b7d 100644
--- a/homeassistant/components/zha/.translations/zh-Hant.json
+++ b/homeassistant/components/zha/.translations/zh-Hant.json
@@ -54,6 +54,14 @@
"device_shaken": "\u8a2d\u5099\u6416\u6643",
"device_slid": "\u63a8\u52d5 \"{subtype}\" \u8a2d\u5099",
"device_tilted": "\u8a2d\u5099\u540d\u7a31",
+ "remote_button_alt_double_press": "\"{subtype}\" \u6309\u9215\u96d9\u64ca\u9375\uff08\u66ff\u4ee3\u6a21\u5f0f\uff09",
+ "remote_button_alt_long_press": "\"{subtype}\" \u6309\u9215\u6301\u7e8c\u6309\u4e0b\uff08\u66ff\u4ee3\u6a21\u5f0f\uff09",
+ "remote_button_alt_long_release": "\"{subtype}\" \u6309\u9215\u9577\u6309\u5f8c\u91cb\u653e\uff08\u66ff\u4ee3\u6a21\u5f0f\uff09",
+ "remote_button_alt_quadruple_press": "\"{subtype}\" \u6309\u9215\u56db\u9023\u64ca\uff08\u66ff\u4ee3\u6a21\u5f0f\uff09",
+ "remote_button_alt_quintuple_press": "\"{subtype}\" \u6309\u9215\u4e94\u9023\u64ca\uff08\u66ff\u4ee3\u6a21\u5f0f\uff09",
+ "remote_button_alt_short_press": "\"{subtype}\" \u6309\u9215\u5df2\u6309\u4e0b\uff08\u66ff\u4ee3\u6a21\u5f0f\uff09",
+ "remote_button_alt_short_release": "\"{subtype}\" \u6309\u9215\u5df2\u91cb\u653e\uff08\u66ff\u4ee3\u6a21\u5f0f\uff09",
+ "remote_button_alt_triple_press": "\"{subtype}\" \u6309\u9215\u4e09\u9023\u64ca\uff08\u66ff\u4ee3\u6a21\u5f0f\uff09",
"remote_button_double_press": "\"{subtype}\" \u6309\u9215\u96d9\u64ca",
"remote_button_long_press": "\"{subtype}\" \u6309\u9215\u6301\u7e8c\u6309\u4e0b",
"remote_button_long_release": "\"{subtype}\" \u6309\u9215\u9577\u6309\u5f8c\u91cb\u653e",
diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py
index 377c77bf601..0d4ceed829b 100644
--- a/homeassistant/components/zha/__init__.py
+++ b/homeassistant/components/zha/__init__.py
@@ -1,5 +1,6 @@
"""Support for Zigbee Home Automation devices."""
+import asyncio
import logging
import voluptuous as vol
@@ -22,6 +23,7 @@ from .core.const import (
DATA_ZHA_CONFIG,
DATA_ZHA_DISPATCHERS,
DATA_ZHA_GATEWAY,
+ DATA_ZHA_PLATFORM_LOADED,
DEFAULT_BAUDRATE,
DEFAULT_RADIO_TYPE,
DOMAIN,
@@ -87,11 +89,23 @@ async def async_setup_entry(hass, config_entry):
Will automatically load components to support devices found on the network.
"""
- for component in COMPONENTS:
- hass.data[DATA_ZHA][component] = hass.data[DATA_ZHA].get(component, {})
-
hass.data[DATA_ZHA] = hass.data.get(DATA_ZHA, {})
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS] = []
+ hass.data[DATA_ZHA][DATA_ZHA_PLATFORM_LOADED] = asyncio.Event()
+ platforms = []
+ for component in COMPONENTS:
+ platforms.append(
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(config_entry, component)
+ )
+ )
+
+ async def _platforms_loaded():
+ await asyncio.gather(*platforms)
+ hass.data[DATA_ZHA][DATA_ZHA_PLATFORM_LOADED].set()
+
+ hass.async_create_task(_platforms_loaded())
+
config = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {})
if config.get(CONF_ENABLE_QUIRKS, True):
@@ -112,11 +126,6 @@ async def async_setup_entry(hass, config_entry):
model=zha_gateway.radio_description,
)
- for component in COMPONENTS:
- hass.async_create_task(
- hass.config_entries.async_forward_entry_setup(config_entry, component)
- )
-
api.async_load_api(hass)
async def async_zha_shutdown(event):
@@ -125,6 +134,7 @@ async def async_setup_entry(hass, config_entry):
await hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].async_update_device_storage()
hass.bus.async_listen_once(ha_const.EVENT_HOMEASSISTANT_STOP, async_zha_shutdown)
+ hass.async_create_task(zha_gateway.async_load_devices())
return True
diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py
index 58b671a340f..6c88f3e1013 100644
--- a/homeassistant/components/zha/binary_sensor.py
+++ b/homeassistant/components/zha/binary_sensor.py
@@ -18,6 +18,7 @@ from homeassistant.const import STATE_ON
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from .core import discovery
from .core.const import (
CHANNEL_ACCELEROMETER,
CHANNEL_OCCUPANCY,
@@ -25,8 +26,8 @@ from .core.const import (
CHANNEL_ZONE,
DATA_ZHA,
DATA_ZHA_DISPATCHERS,
+ SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
- ZHA_DISCOVERY_NEW,
)
from .core.registries import ZHA_ENTITIES
from .entity import ZhaEntity
@@ -48,45 +49,22 @@ STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Zigbee Home Automation binary sensor from config entry."""
-
- async def async_discover(discovery_info):
- await _async_setup_entities(
- hass, config_entry, async_add_entities, [discovery_info]
- )
+ entities_to_create = hass.data[DATA_ZHA][DOMAIN] = []
unsub = async_dispatcher_connect(
- hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover
+ hass,
+ SIGNAL_ADD_ENTITIES,
+ functools.partial(
+ discovery.async_add_entities, async_add_entities, entities_to_create
+ ),
)
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
- binary_sensors = hass.data.get(DATA_ZHA, {}).get(DOMAIN)
- if binary_sensors is not None:
- await _async_setup_entities(
- hass, config_entry, async_add_entities, binary_sensors.values()
- )
- del hass.data[DATA_ZHA][DOMAIN]
-
-
-async def _async_setup_entities(
- hass, config_entry, async_add_entities, discovery_infos
-):
- """Set up the ZHA binary sensors."""
- entities = []
- for discovery_info in discovery_infos:
- zha_dev = discovery_info["zha_device"]
- channels = discovery_info["channels"]
-
- entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, BinarySensor)
- if entity:
- entities.append(entity(**discovery_info))
-
- if entities:
- async_add_entities(entities, update_before_add=True)
-
class BinarySensor(ZhaEntity, BinarySensorDevice):
"""ZHA BinarySensor."""
+ SENSOR_ATTR = None
DEVICE_CLASS = None
def __init__(self, unique_id, zha_device, channels, **kwargs):
@@ -126,22 +104,27 @@ class BinarySensor(ZhaEntity, BinarySensorDevice):
return self._device_class
@callback
- def async_set_state(self, state):
+ def async_set_state(self, attr_id, attr_name, value):
"""Set the state."""
- self._state = bool(state)
- self.async_schedule_update_ha_state()
+ if self.SENSOR_ATTR is None or self.SENSOR_ATTR != attr_name:
+ return
+ self._state = bool(value)
+ self.async_write_ha_state()
async def async_update(self):
"""Attempt to retrieve on off state from the binary sensor."""
await super().async_update()
attribute = getattr(self._channel, "value_attribute", "on_off")
- self._state = await self._channel.get_attribute_value(attribute)
+ attr_value = await self._channel.get_attribute_value(attribute)
+ if attr_value is not None:
+ self._state = attr_value
@STRICT_MATCH(channel_names=CHANNEL_ACCELEROMETER)
class Accelerometer(BinarySensor):
"""ZHA BinarySensor."""
+ SENSOR_ATTR = "acceleration"
DEVICE_CLASS = DEVICE_CLASS_MOVING
@@ -149,6 +132,7 @@ class Accelerometer(BinarySensor):
class Occupancy(BinarySensor):
"""ZHA BinarySensor."""
+ SENSOR_ATTR = "occupancy"
DEVICE_CLASS = DEVICE_CLASS_OCCUPANCY
@@ -156,6 +140,7 @@ class Occupancy(BinarySensor):
class Opening(BinarySensor):
"""ZHA BinarySensor."""
+ SENSOR_ATTR = "on_off"
DEVICE_CLASS = DEVICE_CLASS_OPENING
@@ -163,6 +148,8 @@ class Opening(BinarySensor):
class IASZone(BinarySensor):
"""ZHA IAS BinarySensor."""
+ SENSOR_ATTR = "zone_status"
+
async def get_device_class(self) -> None:
"""Get the HA device class from the channel."""
zone_type = await self._channel.get_attribute_value("zone_type")
diff --git a/homeassistant/components/zha/core/__init__.py b/homeassistant/components/zha/core/__init__.py
index 1873cd7dc55..a416ff2eebe 100644
--- a/homeassistant/components/zha/core/__init__.py
+++ b/homeassistant/components/zha/core/__init__.py
@@ -1,9 +1,4 @@
-"""
-Core module for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Core module for Zigbee Home Automation."""
# flake8: noqa
from .device import ZHADevice
diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py
index d899f51b487..a4848fbaa63 100644
--- a/homeassistant/components/zha/core/channels/__init__.py
+++ b/homeassistant/components/zha/core/channels/__init__.py
@@ -1,399 +1,13 @@
-"""
-Channels module for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Channels module for Zigbee Home Automation."""
import asyncio
-from concurrent.futures import TimeoutError as Timeout
-from enum import Enum
-from functools import wraps
import logging
-from random import uniform
-
-import zigpy.exceptions
+from typing import Any, Dict, List, Optional, Union
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from ..const import (
- CHANNEL_EVENT_RELAY,
- CHANNEL_ZDO,
- REPORT_CONFIG_DEFAULT,
- REPORT_CONFIG_MAX_INT,
- REPORT_CONFIG_MIN_INT,
- REPORT_CONFIG_RPT_CHANGE,
- SIGNAL_ATTR_UPDATED,
-)
-from ..helpers import LogMixin, get_attr_id_by_name, safe_read
-from ..registries import CLUSTER_REPORT_CONFIGS
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def parse_and_log_command(channel, tsn, command_id, args):
- """Parse and log a zigbee cluster command."""
- cmd = channel.cluster.server_commands.get(command_id, [command_id])[0]
- channel.debug(
- "received '%s' command with %s args on cluster_id '%s' tsn '%s'",
- cmd,
- args,
- channel.cluster.cluster_id,
- tsn,
- )
- return cmd
-
-
-def decorate_command(channel, command):
- """Wrap a cluster command to make it safe."""
-
- @wraps(command)
- async def wrapper(*args, **kwds):
- try:
- result = await command(*args, **kwds)
- channel.debug(
- "executed command: %s %s %s %s",
- command.__name__,
- "{}: {}".format("with args", args),
- "{}: {}".format("with kwargs", kwds),
- "{}: {}".format("and result", result),
- )
- return result
-
- except (zigpy.exceptions.DeliveryError, Timeout) as ex:
- channel.debug("command failed: %s exception: %s", command.__name__, str(ex))
- return ex
-
- return wrapper
-
-
-class ChannelStatus(Enum):
- """Status of a channel."""
-
- CREATED = 1
- CONFIGURED = 2
- INITIALIZED = 3
-
-
-class ZigbeeChannel(LogMixin):
- """Base channel for a Zigbee cluster."""
-
- CHANNEL_NAME = None
- REPORT_CONFIG = ()
-
- def __init__(self, cluster, device):
- """Initialize ZigbeeChannel."""
- self._channel_name = cluster.ep_attribute
- if self.CHANNEL_NAME:
- self._channel_name = self.CHANNEL_NAME
- self._generic_id = f"channel_0x{cluster.cluster_id:04x}"
- self._cluster = cluster
- self._zha_device = device
- self._id = f"{cluster.endpoint.endpoint_id}:0x{cluster.cluster_id:04x}"
- self._unique_id = f"{str(device.ieee)}:{self._id}"
- self._report_config = CLUSTER_REPORT_CONFIGS.get(
- self._cluster.cluster_id, self.REPORT_CONFIG
- )
- self._status = ChannelStatus.CREATED
- self._cluster.add_listener(self)
-
- @property
- def id(self) -> str:
- """Return channel id unique for this device only."""
- return self._id
-
- @property
- def generic_id(self):
- """Return the generic id for this channel."""
- return self._generic_id
-
- @property
- def unique_id(self):
- """Return the unique id for this channel."""
- return self._unique_id
-
- @property
- def cluster(self):
- """Return the zigpy cluster for this channel."""
- return self._cluster
-
- @property
- def device(self):
- """Return the device this channel is linked to."""
- return self._zha_device
-
- @property
- def name(self) -> str:
- """Return friendly name."""
- return self._channel_name
-
- @property
- def status(self):
- """Return the status of the channel."""
- return self._status
-
- def set_report_config(self, report_config):
- """Set the reporting configuration."""
- self._report_config = report_config
-
- async def bind(self):
- """Bind a zigbee cluster.
-
- This also swallows DeliveryError exceptions that are thrown when
- devices are unreachable.
- """
- try:
- res = await self.cluster.bind()
- self.debug("bound '%s' cluster: %s", self.cluster.ep_attribute, res[0])
- except (zigpy.exceptions.DeliveryError, Timeout) as ex:
- self.debug(
- "Failed to bind '%s' cluster: %s", self.cluster.ep_attribute, str(ex)
- )
-
- async def configure_reporting(
- self,
- attr,
- report_config=(
- REPORT_CONFIG_MIN_INT,
- REPORT_CONFIG_MAX_INT,
- REPORT_CONFIG_RPT_CHANGE,
- ),
- ):
- """Configure attribute reporting for a cluster.
-
- This also swallows DeliveryError exceptions that are thrown when
- devices are unreachable.
- """
- attr_name = self.cluster.attributes.get(attr, [attr])[0]
-
- kwargs = {}
- if self.cluster.cluster_id >= 0xFC00 and self.device.manufacturer_code:
- kwargs["manufacturer"] = self.device.manufacturer_code
-
- min_report_int, max_report_int, reportable_change = report_config
- try:
- res = await self.cluster.configure_reporting(
- attr, min_report_int, max_report_int, reportable_change, **kwargs
- )
- self.debug(
- "reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'",
- attr_name,
- self.cluster.ep_attribute,
- min_report_int,
- max_report_int,
- reportable_change,
- res,
- )
- except (zigpy.exceptions.DeliveryError, Timeout) as ex:
- self.debug(
- "failed to set reporting for '%s' attr on '%s' cluster: %s",
- attr_name,
- self.cluster.ep_attribute,
- str(ex),
- )
-
- async def async_configure(self):
- """Set cluster binding and attribute reporting."""
- if not self._zha_device.skip_configuration:
- await self.bind()
- if self.cluster.is_server:
- for report_config in self._report_config:
- await self.configure_reporting(
- report_config["attr"], report_config["config"]
- )
- await asyncio.sleep(uniform(0.1, 0.5))
- self.debug("finished channel configuration")
- else:
- self.debug("skipping channel configuration")
- self._status = ChannelStatus.CONFIGURED
-
- async def async_initialize(self, from_cache):
- """Initialize channel."""
- self.debug("initializing channel: from_cache: %s", from_cache)
- self._status = ChannelStatus.INITIALIZED
-
- @callback
- def cluster_command(self, tsn, command_id, args):
- """Handle commands received to this cluster."""
- pass
-
- @callback
- def attribute_updated(self, attrid, value):
- """Handle attribute updates on this cluster."""
- pass
-
- @callback
- def zdo_command(self, *args, **kwargs):
- """Handle ZDO commands on this cluster."""
- pass
-
- @callback
- def zha_send_event(self, cluster, command, args):
- """Relay events to hass."""
- self._zha_device.hass.bus.async_fire(
- "zha_event",
- {
- "unique_id": self._unique_id,
- "device_ieee": str(self._zha_device.ieee),
- "endpoint_id": cluster.endpoint.endpoint_id,
- "cluster_id": cluster.cluster_id,
- "command": command,
- "args": args,
- },
- )
-
- async def async_update(self):
- """Retrieve latest state from cluster."""
- pass
-
- async def get_attribute_value(self, attribute, from_cache=True):
- """Get the value for an attribute."""
- manufacturer = None
- manufacturer_code = self._zha_device.manufacturer_code
- if self.cluster.cluster_id >= 0xFC00 and manufacturer_code:
- manufacturer = manufacturer_code
- result = await safe_read(
- self._cluster,
- [attribute],
- allow_cache=from_cache,
- only_cache=from_cache,
- manufacturer=manufacturer,
- )
- return result.get(attribute)
-
- def log(self, level, msg, *args):
- """Log a message."""
- msg = f"[%s:%s]: {msg}"
- args = (self.device.nwk, self._id) + args
- _LOGGER.log(level, msg, *args)
-
- def __getattr__(self, name):
- """Get attribute or a decorated cluster command."""
- if hasattr(self._cluster, name) and callable(getattr(self._cluster, name)):
- command = getattr(self._cluster, name)
- command.__name__ = name
- return decorate_command(self, command)
- return self.__getattribute__(name)
-
-
-class AttributeListeningChannel(ZigbeeChannel):
- """Channel for attribute reports from the cluster."""
-
- REPORT_CONFIG = [{"attr": 0, "config": REPORT_CONFIG_DEFAULT}]
-
- def __init__(self, cluster, device):
- """Initialize AttributeListeningChannel."""
- super().__init__(cluster, device)
- attr = self._report_config[0].get("attr")
- if isinstance(attr, str):
- self.value_attribute = get_attr_id_by_name(self.cluster, attr)
- else:
- self.value_attribute = attr
-
- @callback
- def attribute_updated(self, attrid, value):
- """Handle attribute updates on this cluster."""
- if attrid == self.value_attribute:
- async_dispatcher_send(
- self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value
- )
-
- async def async_initialize(self, from_cache):
- """Initialize listener."""
- await self.get_attribute_value(
- self._report_config[0].get("attr"), from_cache=from_cache
- )
- await super().async_initialize(from_cache)
-
-
-class ZDOChannel(LogMixin):
- """Channel for ZDO events."""
-
- def __init__(self, cluster, device):
- """Initialize ZDOChannel."""
- self.name = CHANNEL_ZDO
- self._cluster = cluster
- self._zha_device = device
- self._status = ChannelStatus.CREATED
- self._unique_id = "{}:{}_ZDO".format(str(device.ieee), device.name)
- self._cluster.add_listener(self)
-
- @property
- def unique_id(self):
- """Return the unique id for this channel."""
- return self._unique_id
-
- @property
- def cluster(self):
- """Return the aigpy cluster for this channel."""
- return self._cluster
-
- @property
- def status(self):
- """Return the status of the channel."""
- return self._status
-
- @callback
- def device_announce(self, zigpy_device):
- """Device announce handler."""
- pass
-
- @callback
- def permit_duration(self, duration):
- """Permit handler."""
- pass
-
- async def async_initialize(self, from_cache):
- """Initialize channel."""
- entry = self._zha_device.gateway.zha_storage.async_get_or_create(
- self._zha_device
- )
- self.debug("entry loaded from storage: %s", entry)
- self._status = ChannelStatus.INITIALIZED
-
- async def async_configure(self):
- """Configure channel."""
- self._status = ChannelStatus.CONFIGURED
-
- def log(self, level, msg, *args):
- """Log a message."""
- msg = f"[%s:ZDO](%s): {msg}"
- args = (self._zha_device.nwk, self._zha_device.model) + args
- _LOGGER.log(level, msg, *args)
-
-
-class EventRelayChannel(ZigbeeChannel):
- """Event relay that can be attached to zigbee clusters."""
-
- CHANNEL_NAME = CHANNEL_EVENT_RELAY
-
- @callback
- def attribute_updated(self, attrid, value):
- """Handle an attribute updated on this cluster."""
- self.zha_send_event(
- self._cluster,
- SIGNAL_ATTR_UPDATED,
- {
- "attribute_id": attrid,
- "attribute_name": self._cluster.attributes.get(attrid, ["Unknown"])[0],
- "value": value,
- },
- )
-
- @callback
- def cluster_command(self, tsn, command_id, args):
- """Handle a cluster command received on this cluster."""
- if (
- self._cluster.server_commands is not None
- and self._cluster.server_commands.get(command_id) is not None
- ):
- self.zha_send_event(
- self._cluster, self._cluster.server_commands.get(command_id)[0], args
- )
-
-
-# pylint: disable=wrong-import-position, import-outside-toplevel
-from . import ( # noqa: F401 isort:skip
+from . import ( # noqa: F401 # pylint: disable=unused-import
+ base,
closures,
general,
homeautomation,
@@ -406,3 +20,323 @@ from . import ( # noqa: F401 isort:skip
security,
smartenergy,
)
+from .. import (
+ const,
+ device as zha_core_device,
+ discovery as zha_disc,
+ registries as zha_regs,
+ typing as zha_typing,
+)
+
+_LOGGER = logging.getLogger(__name__)
+ChannelsDict = Dict[str, zha_typing.ChannelType]
+
+
+class Channels:
+ """All discovered channels of a device."""
+
+ def __init__(self, zha_device: zha_typing.ZhaDeviceType) -> None:
+ """Initialize instance."""
+ self._pools: List[zha_typing.ChannelPoolType] = []
+ self._power_config = None
+ self._identify = None
+ self._semaphore = asyncio.Semaphore(3)
+ self._unique_id = str(zha_device.ieee)
+ self._zdo_channel = base.ZDOChannel(zha_device.device.endpoints[0], zha_device)
+ self._zha_device = zha_device
+
+ @property
+ def pools(self) -> List["ChannelPool"]:
+ """Return channel pools list."""
+ return self._pools
+
+ @property
+ def power_configuration_ch(self) -> zha_typing.ChannelType:
+ """Return power configuration channel."""
+ return self._power_config
+
+ @power_configuration_ch.setter
+ def power_configuration_ch(self, channel: zha_typing.ChannelType) -> None:
+ """Power configuration channel setter."""
+ if self._power_config is None:
+ self._power_config = channel
+
+ @property
+ def identify_ch(self) -> zha_typing.ChannelType:
+ """Return power configuration channel."""
+ return self._identify
+
+ @identify_ch.setter
+ def identify_ch(self, channel: zha_typing.ChannelType) -> None:
+ """Power configuration channel setter."""
+ if self._identify is None:
+ self._identify = channel
+
+ @property
+ def semaphore(self) -> asyncio.Semaphore:
+ """Return semaphore for concurrent tasks."""
+ return self._semaphore
+
+ @property
+ def zdo_channel(self) -> zha_typing.ZDOChannelType:
+ """Return ZDO channel."""
+ return self._zdo_channel
+
+ @property
+ def zha_device(self) -> zha_typing.ZhaDeviceType:
+ """Return parent zha device."""
+ return self._zha_device
+
+ @property
+ def unique_id(self):
+ """Return the unique id for this channel."""
+ return self._unique_id
+
+ @classmethod
+ def new(cls, zha_device: zha_typing.ZhaDeviceType) -> "Channels":
+ """Create new instance."""
+ channels = cls(zha_device)
+ for ep_id in sorted(zha_device.device.endpoints):
+ channels.add_pool(ep_id)
+ return channels
+
+ def add_pool(self, ep_id: int) -> None:
+ """Add channels for a specific endpoint."""
+ if ep_id == 0:
+ return
+ self._pools.append(ChannelPool.new(self, ep_id))
+
+ async def async_initialize(self, from_cache: bool = False) -> None:
+ """Initialize claimed channels."""
+ await self.zdo_channel.async_initialize(from_cache)
+ self.zdo_channel.debug("'async_initialize' stage succeeded")
+ await asyncio.gather(
+ *(pool.async_initialize(from_cache) for pool in self.pools)
+ )
+
+ async def async_configure(self) -> None:
+ """Configure claimed channels."""
+ await self.zdo_channel.async_configure()
+ self.zdo_channel.debug("'async_configure' stage succeeded")
+ await asyncio.gather(*(pool.async_configure() for pool in self.pools))
+
+ @callback
+ def async_new_entity(
+ self,
+ component: str,
+ entity_class: zha_typing.CALLABLE_T,
+ unique_id: str,
+ channels: List[zha_typing.ChannelType],
+ ):
+ """Signal new entity addition."""
+ if self.zha_device.status == zha_core_device.DeviceStatus.INITIALIZED:
+ return
+
+ self.zha_device.hass.data[const.DATA_ZHA][component].append(
+ (entity_class, (unique_id, self.zha_device, channels))
+ )
+
+ @callback
+ def async_send_signal(self, signal: str, *args: Any) -> None:
+ """Send a signal through hass dispatcher."""
+ async_dispatcher_send(self.zha_device.hass, signal, *args)
+
+ @callback
+ def zha_send_event(self, event_data: Dict[str, Union[str, int]]) -> None:
+ """Relay events to hass."""
+ self.zha_device.hass.bus.async_fire(
+ "zha_event",
+ {
+ const.ATTR_DEVICE_IEEE: str(self.zha_device.ieee),
+ const.ATTR_UNIQUE_ID: self.unique_id,
+ **event_data,
+ },
+ )
+
+
+class ChannelPool:
+ """All channels of an endpoint."""
+
+ def __init__(self, channels: Channels, ep_id: int):
+ """Initialize instance."""
+ self._all_channels: ChannelsDict = {}
+ self._channels: Channels = channels
+ self._claimed_channels: ChannelsDict = {}
+ self._id: int = ep_id
+ self._relay_channels: Dict[str, zha_typing.EventRelayChannelType] = {}
+ self._unique_id: str = f"{channels.unique_id}-{ep_id}"
+
+ @property
+ def all_channels(self) -> ChannelsDict:
+ """All channels of an endpoint."""
+ return self._all_channels
+
+ @property
+ def claimed_channels(self) -> ChannelsDict:
+ """Channels in use."""
+ return self._claimed_channels
+
+ @property
+ def endpoint(self) -> zha_typing.ZigpyEndpointType:
+ """Return endpoint of zigpy device."""
+ return self._channels.zha_device.device.endpoints[self.id]
+
+ @property
+ def id(self) -> int:
+ """Return endpoint id."""
+ return self._id
+
+ @property
+ def nwk(self) -> int:
+ """Device NWK for logging."""
+ return self._channels.zha_device.nwk
+
+ @property
+ def is_mains_powered(self) -> bool:
+ """Device is_mains_powered."""
+ return self._channels.zha_device.is_mains_powered
+
+ @property
+ def manufacturer(self) -> Optional[str]:
+ """Return device manufacturer."""
+ return self._channels.zha_device.manufacturer
+
+ @property
+ def manufacturer_code(self) -> Optional[int]:
+ """Return device manufacturer."""
+ return self._channels.zha_device.manufacturer_code
+
+ @property
+ def hass(self):
+ """Return hass."""
+ return self._channels.zha_device.hass
+
+ @property
+ def model(self) -> Optional[str]:
+ """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."""
+ return self._channels.zha_device.skip_configuration
+
+ @property
+ def unique_id(self):
+ """Return the unique id for this channel."""
+ return self._unique_id
+
+ @classmethod
+ def new(cls, channels: Channels, ep_id: int) -> "ChannelPool":
+ """Create new channels for an endpoint."""
+ pool = cls(channels, ep_id)
+ pool.add_all_channels()
+ pool.add_relay_channels()
+ zha_disc.PROBE.discover_entities(pool)
+ return pool
+
+ @callback
+ def add_all_channels(self) -> None:
+ """Create and add channels for all input clusters."""
+ for cluster_id, cluster in self.endpoint.in_clusters.items():
+ channel_class = zha_regs.ZIGBEE_CHANNEL_REGISTRY.get(
+ cluster_id, base.ZigbeeChannel
+ )
+ # really ugly hack to deal with xiaomi using the door lock cluster
+ # incorrectly.
+ if (
+ hasattr(cluster, "ep_attribute")
+ and cluster.ep_attribute == "multistate_input"
+ ):
+ channel_class = base.ZigbeeChannel
+ # end of ugly hack
+ channel = channel_class(cluster, self)
+ if channel.name == const.CHANNEL_POWER_CONFIGURATION:
+ if (
+ self._channels.power_configuration_ch
+ or self._channels.zha_device.is_mains_powered
+ ):
+ # on power configuration channel per device
+ continue
+ self._channels.power_configuration_ch = channel
+ elif channel.name == const.CHANNEL_IDENTIFY:
+ self._channels.identify_ch = channel
+
+ 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:
+ cluster = self.endpoint.out_clusters.get(cluster_id)
+ if cluster is not None:
+ channel = base.EventRelayChannel(cluster, self)
+ self.relay_channels[channel.id] = channel
+
+ async def async_initialize(self, from_cache: bool = False) -> None:
+ """Initialize claimed channels."""
+ await self._execute_channel_tasks("async_initialize", from_cache)
+
+ async def async_configure(self) -> None:
+ """Configure claimed channels."""
+ await self._execute_channel_tasks("async_configure")
+
+ async def _execute_channel_tasks(self, func_name: str, *args: Any) -> None:
+ """Add a throttled channel task and swallow exceptions."""
+
+ async def _throttle(coro):
+ async with self._channels.semaphore:
+ return await coro
+
+ channels = [*self.claimed_channels.values(), *self.relay_channels.values()]
+ tasks = [_throttle(getattr(ch, func_name)(*args)) for ch in channels]
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+ for channel, outcome in zip(channels, results):
+ if isinstance(outcome, Exception):
+ channel.warning("'%s' stage failed: %s", func_name, str(outcome))
+ continue
+ channel.debug("'%s' stage succeeded", func_name)
+
+ @callback
+ def async_new_entity(
+ self,
+ component: str,
+ entity_class: zha_typing.CALLABLE_T,
+ unique_id: str,
+ channels: List[zha_typing.ChannelType],
+ ):
+ """Signal new entity addition."""
+ self._channels.async_new_entity(component, entity_class, unique_id, channels)
+
+ @callback
+ def async_send_signal(self, signal: str, *args: Any) -> None:
+ """Send a signal through hass dispatcher."""
+ self._channels.async_send_signal(signal, *args)
+
+ @callback
+ def claim_channels(self, channels: List[zha_typing.ChannelType]) -> None:
+ """Claim a channel."""
+ self.claimed_channels.update({ch.id: ch for ch in channels})
+
+ @callback
+ def unclaimed_channels(self) -> List[zha_typing.ChannelType]:
+ """Return a list of available (unclaimed) channels."""
+ claimed = set(self.claimed_channels)
+ available = set(self.all_channels)
+ return [self.all_channels[chan_id] for chan_id in (available - claimed)]
+
+ @callback
+ def zha_send_event(self, event_data: Dict[str, Union[str, int]]) -> None:
+ """Relay events to hass."""
+ self._channels.zha_send_event(
+ {
+ const.ATTR_UNIQUE_ID: self.unique_id,
+ const.ATTR_ENDPOINT_ID: self.id,
+ **event_data,
+ }
+ )
diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py
new file mode 100644
index 00000000000..dca0bbe09f3
--- /dev/null
+++ b/homeassistant/components/zha/core/channels/base.py
@@ -0,0 +1,390 @@
+"""Base classes for channels."""
+
+import asyncio
+from enum import Enum
+from functools import wraps
+import logging
+from typing import Any, Union
+
+import zigpy.exceptions
+
+from homeassistant.core import callback
+
+from .. import typing as zha_typing
+from ..const import (
+ ATTR_ARGS,
+ ATTR_ATTRIBUTE_ID,
+ ATTR_ATTRIBUTE_NAME,
+ ATTR_CLUSTER_ID,
+ ATTR_COMMAND,
+ ATTR_UNIQUE_ID,
+ ATTR_VALUE,
+ CHANNEL_EVENT_RELAY,
+ CHANNEL_ZDO,
+ REPORT_CONFIG_MAX_INT,
+ REPORT_CONFIG_MIN_INT,
+ REPORT_CONFIG_RPT_CHANGE,
+ SIGNAL_ATTR_UPDATED,
+)
+from ..helpers import LogMixin, safe_read
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def parse_and_log_command(channel, tsn, command_id, args):
+ """Parse and log a zigbee cluster command."""
+ cmd = channel.cluster.server_commands.get(command_id, [command_id])[0]
+ channel.debug(
+ "received '%s' command with %s args on cluster_id '%s' tsn '%s'",
+ cmd,
+ args,
+ channel.cluster.cluster_id,
+ tsn,
+ )
+ return cmd
+
+
+def decorate_command(channel, command):
+ """Wrap a cluster command to make it safe."""
+
+ @wraps(command)
+ async def wrapper(*args, **kwds):
+ try:
+ result = await command(*args, **kwds)
+ channel.debug(
+ "executed '%s' command with args: '%s' kwargs: '%s' result: %s",
+ command.__name__,
+ args,
+ kwds,
+ result,
+ )
+ return result
+
+ except (zigpy.exceptions.DeliveryError, asyncio.TimeoutError) as ex:
+ channel.debug("command failed: %s exception: %s", command.__name__, str(ex))
+ return ex
+
+ return wrapper
+
+
+class ChannelStatus(Enum):
+ """Status of a channel."""
+
+ CREATED = 1
+ CONFIGURED = 2
+ INITIALIZED = 3
+
+
+class ZigbeeChannel(LogMixin):
+ """Base channel for a Zigbee cluster."""
+
+ CHANNEL_NAME = None
+ REPORT_CONFIG = ()
+
+ def __init__(
+ self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType
+ ) -> None:
+ """Initialize ZigbeeChannel."""
+ self._channel_name = cluster.ep_attribute
+ if self.CHANNEL_NAME:
+ self._channel_name = self.CHANNEL_NAME
+ self._ch_pool = ch_pool
+ self._generic_id = f"channel_0x{cluster.cluster_id:04x}"
+ self._cluster = cluster
+ self._id = f"{ch_pool.id}:0x{cluster.cluster_id:04x}"
+ unique_id = ch_pool.unique_id.replace("-", ":")
+ self._unique_id = f"{unique_id}:0x{cluster.cluster_id:04x}"
+ self._report_config = self.REPORT_CONFIG
+ if not hasattr(self, "_value_attribute") and len(self._report_config) > 0:
+ attr = self._report_config[0].get("attr")
+ if isinstance(attr, str):
+ self.value_attribute = self.cluster.attridx.get(attr)
+ else:
+ self.value_attribute = attr
+ self._status = ChannelStatus.CREATED
+ self._cluster.add_listener(self)
+
+ @property
+ def id(self) -> str:
+ """Return channel id unique for this device only."""
+ return self._id
+
+ @property
+ def generic_id(self):
+ """Return the generic id for this channel."""
+ return self._generic_id
+
+ @property
+ def unique_id(self):
+ """Return the unique id for this channel."""
+ return self._unique_id
+
+ @property
+ def cluster(self):
+ """Return the zigpy cluster for this channel."""
+ return self._cluster
+
+ @property
+ def name(self) -> str:
+ """Return friendly name."""
+ return self._channel_name
+
+ @property
+ def status(self):
+ """Return the status of the channel."""
+ return self._status
+
+ @callback
+ def async_send_signal(self, signal: str, *args: Any) -> None:
+ """Send a signal through hass dispatcher."""
+ self._ch_pool.async_send_signal(signal, *args)
+
+ async def bind(self):
+ """Bind a zigbee cluster.
+
+ This also swallows DeliveryError exceptions that are thrown when
+ devices are unreachable.
+ """
+ try:
+ res = await self.cluster.bind()
+ self.debug("bound '%s' cluster: %s", self.cluster.ep_attribute, res[0])
+ except (zigpy.exceptions.DeliveryError, asyncio.TimeoutError) as ex:
+ self.debug(
+ "Failed to bind '%s' cluster: %s", self.cluster.ep_attribute, str(ex)
+ )
+
+ async def configure_reporting(
+ self,
+ attr,
+ report_config=(
+ REPORT_CONFIG_MIN_INT,
+ REPORT_CONFIG_MAX_INT,
+ REPORT_CONFIG_RPT_CHANGE,
+ ),
+ ):
+ """Configure attribute reporting for a cluster.
+
+ This also swallows DeliveryError exceptions that are thrown when
+ devices are unreachable.
+ """
+ attr_name = self.cluster.attributes.get(attr, [attr])[0]
+
+ kwargs = {}
+ if self.cluster.cluster_id >= 0xFC00 and self._ch_pool.manufacturer_code:
+ kwargs["manufacturer"] = self._ch_pool.manufacturer_code
+
+ min_report_int, max_report_int, reportable_change = report_config
+ try:
+ res = await self.cluster.configure_reporting(
+ attr, min_report_int, max_report_int, reportable_change, **kwargs
+ )
+ self.debug(
+ "reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'",
+ attr_name,
+ self.cluster.ep_attribute,
+ min_report_int,
+ max_report_int,
+ reportable_change,
+ res,
+ )
+ except (zigpy.exceptions.DeliveryError, asyncio.TimeoutError) as ex:
+ self.debug(
+ "failed to set reporting for '%s' attr on '%s' cluster: %s",
+ attr_name,
+ self.cluster.ep_attribute,
+ str(ex),
+ )
+
+ async def async_configure(self):
+ """Set cluster binding and attribute reporting."""
+ if not self._ch_pool.skip_configuration:
+ await self.bind()
+ if self.cluster.is_server:
+ for report_config in self._report_config:
+ await self.configure_reporting(
+ report_config["attr"], report_config["config"]
+ )
+ self.debug("finished channel configuration")
+ else:
+ self.debug("skipping channel configuration")
+ self._status = ChannelStatus.CONFIGURED
+
+ async def async_initialize(self, from_cache):
+ """Initialize channel."""
+ self.debug("initializing channel: from_cache: %s", from_cache)
+ attributes = []
+ for report_config in self._report_config:
+ attributes.append(report_config["attr"])
+ if len(attributes) > 0:
+ await self.get_attributes(attributes, from_cache=from_cache)
+ self._status = ChannelStatus.INITIALIZED
+
+ @callback
+ def cluster_command(self, tsn, command_id, args):
+ """Handle commands received to this cluster."""
+ pass
+
+ @callback
+ def attribute_updated(self, attrid, value):
+ """Handle attribute updates on this cluster."""
+ self.async_send_signal(
+ f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}",
+ attrid,
+ self.cluster.attributes.get(attrid, [attrid])[0],
+ value,
+ )
+
+ @callback
+ def zdo_command(self, *args, **kwargs):
+ """Handle ZDO commands on this cluster."""
+ pass
+
+ @callback
+ def zha_send_event(self, command: str, args: Union[int, dict]) -> None:
+ """Relay events to hass."""
+ self._ch_pool.zha_send_event(
+ {
+ ATTR_UNIQUE_ID: self.unique_id,
+ ATTR_CLUSTER_ID: self.cluster.cluster_id,
+ ATTR_COMMAND: command,
+ ATTR_ARGS: args,
+ }
+ )
+
+ async def async_update(self):
+ """Retrieve latest state from cluster."""
+ pass
+
+ async def get_attribute_value(self, attribute, from_cache=True):
+ """Get the value for an attribute."""
+ manufacturer = None
+ manufacturer_code = self._ch_pool.manufacturer_code
+ if self.cluster.cluster_id >= 0xFC00 and manufacturer_code:
+ manufacturer = manufacturer_code
+ result = await safe_read(
+ self._cluster,
+ [attribute],
+ allow_cache=from_cache,
+ only_cache=from_cache,
+ manufacturer=manufacturer,
+ )
+ return result.get(attribute)
+
+ async def get_attributes(self, attributes, from_cache=True):
+ """Get the values for a list of attributes."""
+ manufacturer = None
+ manufacturer_code = self._ch_pool.manufacturer_code
+ if self.cluster.cluster_id >= 0xFC00 and manufacturer_code:
+ manufacturer = manufacturer_code
+ try:
+ result, _ = await self.cluster.read_attributes(
+ attributes,
+ allow_cache=from_cache,
+ only_cache=from_cache,
+ manufacturer=manufacturer,
+ )
+ results = {attribute: result.get(attribute) for attribute in attributes}
+ except (asyncio.TimeoutError, zigpy.exceptions.DeliveryError) as ex:
+ self.debug(
+ "failed to get attributes '%s' on '%s' cluster: %s",
+ attributes,
+ self.cluster.ep_attribute,
+ str(ex),
+ )
+ results = {}
+ return results
+
+ def log(self, level, msg, *args):
+ """Log a message."""
+ msg = f"[%s:%s]: {msg}"
+ args = (self._ch_pool.nwk, self._id) + args
+ _LOGGER.log(level, msg, *args)
+
+ def __getattr__(self, name):
+ """Get attribute or a decorated cluster command."""
+ if hasattr(self._cluster, name) and callable(getattr(self._cluster, name)):
+ command = getattr(self._cluster, name)
+ command.__name__ = name
+ return decorate_command(self, command)
+ return self.__getattribute__(name)
+
+
+class ZDOChannel(LogMixin):
+ """Channel for ZDO events."""
+
+ def __init__(self, cluster, device):
+ """Initialize ZDOChannel."""
+ self.name = CHANNEL_ZDO
+ self._cluster = cluster
+ self._zha_device = device
+ self._status = ChannelStatus.CREATED
+ self._unique_id = "{}:{}_ZDO".format(str(device.ieee), device.name)
+ self._cluster.add_listener(self)
+
+ @property
+ def unique_id(self):
+ """Return the unique id for this channel."""
+ return self._unique_id
+
+ @property
+ def cluster(self):
+ """Return the aigpy cluster for this channel."""
+ return self._cluster
+
+ @property
+ def status(self):
+ """Return the status of the channel."""
+ return self._status
+
+ @callback
+ def device_announce(self, zigpy_device):
+ """Device announce handler."""
+ pass
+
+ @callback
+ def permit_duration(self, duration):
+ """Permit handler."""
+ pass
+
+ async def async_initialize(self, from_cache):
+ """Initialize channel."""
+ self._status = ChannelStatus.INITIALIZED
+
+ async def async_configure(self):
+ """Configure channel."""
+ self._status = ChannelStatus.CONFIGURED
+
+ def log(self, level, msg, *args):
+ """Log a message."""
+ msg = f"[%s:ZDO](%s): {msg}"
+ args = (self._zha_device.nwk, self._zha_device.model) + args
+ _LOGGER.log(level, msg, *args)
+
+
+class EventRelayChannel(ZigbeeChannel):
+ """Event relay that can be attached to zigbee clusters."""
+
+ CHANNEL_NAME = CHANNEL_EVENT_RELAY
+
+ @callback
+ def attribute_updated(self, attrid, value):
+ """Handle an attribute updated on this cluster."""
+ self.zha_send_event(
+ SIGNAL_ATTR_UPDATED,
+ {
+ ATTR_ATTRIBUTE_ID: attrid,
+ ATTR_ATTRIBUTE_NAME: self._cluster.attributes.get(attrid, ["Unknown"])[
+ 0
+ ],
+ ATTR_VALUE: value,
+ },
+ )
+
+ @callback
+ def cluster_command(self, tsn, command_id, args):
+ """Handle a cluster command received on this cluster."""
+ if (
+ self._cluster.server_commands is not None
+ and self._cluster.server_commands.get(command_id) is not None
+ ):
+ self.zha_send_event(self._cluster.server_commands.get(command_id)[0], args)
diff --git a/homeassistant/components/zha/core/channels/closures.py b/homeassistant/components/zha/core/channels/closures.py
index 03b1a8450db..2b6c06ba12a 100644
--- a/homeassistant/components/zha/core/channels/closures.py
+++ b/homeassistant/components/zha/core/channels/closures.py
@@ -1,19 +1,13 @@
-"""
-Closures channels module for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Closures channels module for Zigbee Home Automation."""
import logging
import zigpy.zcl.clusters.closures as closures
from homeassistant.core import callback
-from homeassistant.helpers.dispatcher import async_dispatcher_send
-from . import ZigbeeChannel
from .. import registries
from ..const import REPORT_CONFIG_IMMEDIATE, SIGNAL_ATTR_UPDATED
+from .base import ZigbeeChannel
_LOGGER = logging.getLogger(__name__)
@@ -28,10 +22,10 @@ class DoorLockChannel(ZigbeeChannel):
async def async_update(self):
"""Retrieve latest state."""
result = await self.get_attribute_value("lock_state", from_cache=True)
-
- async_dispatcher_send(
- self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result
- )
+ if result is not None:
+ self.async_send_signal(
+ f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "lock_state", result
+ )
@callback
def attribute_updated(self, attrid, value):
@@ -41,8 +35,8 @@ class DoorLockChannel(ZigbeeChannel):
"Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value
)
if attrid == self._value_attribute:
- async_dispatcher_send(
- self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value
+ self.async_send_signal(
+ f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value
)
async def async_initialize(self, from_cache):
@@ -73,10 +67,13 @@ class WindowCovering(ZigbeeChannel):
"current_position_lift_percentage", from_cache=False
)
self.debug("read current position: %s", result)
-
- async_dispatcher_send(
- self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result
- )
+ if result is not None:
+ self.async_send_signal(
+ f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}",
+ 8,
+ "current_position_lift_percentage",
+ result,
+ )
@callback
def attribute_updated(self, attrid, value):
@@ -86,8 +83,8 @@ class WindowCovering(ZigbeeChannel):
"Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value
)
if attrid == self._value_attribute:
- async_dispatcher_send(
- self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value
+ self.async_send_signal(
+ f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value
)
async def async_initialize(self, from_cache):
diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py
index c1701479a43..783188248f3 100644
--- a/homeassistant/components/zha/core/channels/general.py
+++ b/homeassistant/components/zha/core/channels/general.py
@@ -1,19 +1,15 @@
-"""
-General channels module for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""General channels module for Zigbee Home Automation."""
+import asyncio
import logging
+from typing import Any, List, Optional
+import zigpy.exceptions
import zigpy.zcl.clusters.general as general
from homeassistant.core import callback
-from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
-from . import AttributeListeningChannel, ZigbeeChannel, parse_and_log_command
-from .. import registries
+from .. import registries, typing as zha_typing
from ..const import (
REPORT_CONFIG_ASAP,
REPORT_CONFIG_BATTERY_SAVE,
@@ -24,7 +20,7 @@ from ..const import (
SIGNAL_SET_LEVEL,
SIGNAL_STATE_ATTR,
)
-from ..helpers import get_attr_id_by_name
+from .base import ZigbeeChannel, parse_and_log_command
_LOGGER = logging.getLogger(__name__)
@@ -37,21 +33,21 @@ class Alarms(ZigbeeChannel):
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogInput.cluster_id)
-class AnalogInput(AttributeListeningChannel):
+class AnalogInput(ZigbeeChannel):
"""Analog Input channel."""
REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}]
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogOutput.cluster_id)
-class AnalogOutput(AttributeListeningChannel):
+class AnalogOutput(ZigbeeChannel):
"""Analog Output channel."""
REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}]
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogValue.cluster_id)
-class AnalogValue(AttributeListeningChannel):
+class AnalogValue(ZigbeeChannel):
"""Analog Value channel."""
REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}]
@@ -82,9 +78,11 @@ class BasicChannel(ZigbeeChannel):
6: "Emergency mains and transfer switch",
}
- def __init__(self, cluster, device):
+ def __init__(
+ self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType
+ ) -> None:
"""Initialize BasicChannel."""
- super().__init__(cluster, device)
+ super().__init__(cluster, ch_pool)
self._power_source = None
async def async_configure(self):
@@ -94,9 +92,11 @@ class BasicChannel(ZigbeeChannel):
async def async_initialize(self, from_cache):
"""Initialize channel."""
- self._power_source = await self.get_attribute_value(
+ power_source = await self.get_attribute_value(
"power_source", from_cache=from_cache
)
+ if power_source is not None:
+ self._power_source = power_source
await super().async_initialize(from_cache)
def get_power_source(self):
@@ -105,21 +105,21 @@ class BasicChannel(ZigbeeChannel):
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryInput.cluster_id)
-class BinaryInput(AttributeListeningChannel):
+class BinaryInput(ZigbeeChannel):
"""Binary Input channel."""
REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}]
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryOutput.cluster_id)
-class BinaryOutput(AttributeListeningChannel):
+class BinaryOutput(ZigbeeChannel):
"""Binary Output channel."""
REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}]
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryValue.cluster_id)
-class BinaryValue(AttributeListeningChannel):
+class BinaryValue(ZigbeeChannel):
"""Binary Value channel."""
REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}]
@@ -157,7 +157,13 @@ class Groups(ZigbeeChannel):
class Identify(ZigbeeChannel):
"""Identify channel."""
- pass
+ @callback
+ def cluster_command(self, tsn, command_id, args):
+ """Handle commands received to this cluster."""
+ cmd = parse_and_log_command(self, tsn, command_id, args)
+
+ if cmd == "trigger_effect":
+ self.async_send_signal(f"{self.unique_id}_{cmd}", args[0])
@registries.BINDABLE_CLUSTERS.register(general.LevelControl.cluster_id)
@@ -198,9 +204,7 @@ class LevelControlChannel(ZigbeeChannel):
def dispatch_level_change(self, command, level):
"""Dispatch level change."""
- async_dispatcher_send(
- self._zha_device.hass, f"{self.unique_id}_{command}", level
- )
+ self.async_send_signal(f"{self.unique_id}_{command}", level)
async def async_initialize(self, from_cache):
"""Initialize channel."""
@@ -209,21 +213,21 @@ class LevelControlChannel(ZigbeeChannel):
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateInput.cluster_id)
-class MultistateInput(AttributeListeningChannel):
+class MultistateInput(ZigbeeChannel):
"""Multistate Input channel."""
REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}]
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateOutput.cluster_id)
-class MultistateOutput(AttributeListeningChannel):
+class MultistateOutput(ZigbeeChannel):
"""Multistate Output channel."""
REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}]
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateValue.cluster_id)
-class MultistateValue(AttributeListeningChannel):
+class MultistateValue(ZigbeeChannel):
"""Multistate Value channel."""
REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}]
@@ -241,9 +245,11 @@ class OnOffChannel(ZigbeeChannel):
ON_OFF = 0
REPORT_CONFIG = ({"attr": "on_off", "config": REPORT_CONFIG_IMMEDIATE},)
- def __init__(self, cluster, device):
+ def __init__(
+ self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType
+ ) -> None:
"""Initialize OnOffChannel."""
- super().__init__(cluster, device)
+ super().__init__(cluster, ch_pool)
self._state = None
self._off_listener = None
@@ -267,7 +273,7 @@ class OnOffChannel(ZigbeeChannel):
self.attribute_updated(self.ON_OFF, True)
if on_time > 0:
self._off_listener = async_call_later(
- self.device.hass,
+ self._ch_pool.hass,
(on_time / 10), # value is in 10ths of a second
self.set_to_off,
)
@@ -284,25 +290,27 @@ class OnOffChannel(ZigbeeChannel):
def attribute_updated(self, attrid, value):
"""Handle attribute updates on this cluster."""
if attrid == self.ON_OFF:
- async_dispatcher_send(
- self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value
+ self.async_send_signal(
+ f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, "on_off", value
)
self._state = bool(value)
async def async_initialize(self, from_cache):
"""Initialize channel."""
- self._state = bool(
- await self.get_attribute_value(self.ON_OFF, from_cache=from_cache)
- )
+ state = await self.get_attribute_value(self.ON_OFF, from_cache=from_cache)
+ if state is not None:
+ self._state = bool(state)
await super().async_initialize(from_cache)
async def async_update(self):
"""Initialize channel."""
- from_cache = not self.device.is_mains_powered
+ if self.cluster.is_client:
+ return
+ from_cache = not self._ch_pool.is_mains_powered
self.debug("attempting to update onoff state - from cache: %s", from_cache)
- self._state = bool(
- await self.get_attribute_value(self.ON_OFF, from_cache=from_cache)
- )
+ state = await self.get_attribute_value(self.ON_OFF, from_cache=from_cache)
+ if state is not None:
+ self._state = bool(state)
await super().async_update()
@@ -327,11 +335,41 @@ class Partition(ZigbeeChannel):
pass
+@registries.CHANNEL_ONLY_CLUSTERS.register(general.PollControl.cluster_id)
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.PollControl.cluster_id)
class PollControl(ZigbeeChannel):
"""Poll Control channel."""
- pass
+ CHECKIN_INTERVAL = 55 * 60 * 4 # 55min
+ CHECKIN_FAST_POLL_TIMEOUT = 2 * 4 # 2s
+ LONG_POLL = 6 * 4 # 6s
+
+ async def async_configure(self) -> None:
+ """Configure channel: set check-in interval."""
+ try:
+ res = await self.cluster.write_attributes(
+ {"checkin_interval": self.CHECKIN_INTERVAL}
+ )
+ self.debug("%ss check-in interval set: %s", self.CHECKIN_INTERVAL / 4, res)
+ except (asyncio.TimeoutError, zigpy.exceptions.ZigbeeException) as ex:
+ self.debug("Couldn't set check-in interval: %s", ex)
+ await super().async_configure()
+
+ @callback
+ def cluster_command(
+ self, tsn: int, command_id: int, args: Optional[List[Any]]
+ ) -> None:
+ """Handle commands received to this cluster."""
+ cmd_name = self.cluster.client_commands.get(command_id, [command_id])[0]
+ self.debug("Received %s tsn command '%s': %s", tsn, cmd_name, args)
+ self.zha_send_event(cmd_name, args)
+ if cmd_name == "checkin":
+ self.cluster.create_catching_task(self.check_in_response(tsn))
+
+ async def check_in_response(self, tsn: int) -> None:
+ """Respond to checkin command."""
+ await self.checkin_response(True, self.CHECKIN_FAST_POLL_TIMEOUT, tsn=tsn)
+ await self.set_long_poll_interval(self.LONG_POLL)
@registries.DEVICE_TRACKER_CLUSTERS.register(general.PowerConfiguration.cluster_id)
@@ -349,20 +387,20 @@ class PowerConfigurationChannel(ZigbeeChannel):
"""Handle attribute updates on this cluster."""
attr = self._report_config[1].get("attr")
if isinstance(attr, str):
- attr_id = get_attr_id_by_name(self.cluster, attr)
+ attr_id = self.cluster.attridx.get(attr)
else:
attr_id = attr
if attrid == attr_id:
- async_dispatcher_send(
- self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value
+ self.async_send_signal(
+ f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}",
+ attrid,
+ self.cluster.attributes.get(attrid, [attrid])[0],
+ value,
)
return
attr_name = self.cluster.attributes.get(attrid, [attrid])[0]
- async_dispatcher_send(
- self._zha_device.hass,
- f"{self.unique_id}_{SIGNAL_STATE_ATTR}",
- attr_name,
- value,
+ self.async_send_signal(
+ f"{self.unique_id}_{SIGNAL_STATE_ATTR}", attr_name, value
)
async def async_initialize(self, from_cache):
@@ -376,12 +414,13 @@ class PowerConfigurationChannel(ZigbeeChannel):
async def async_read_state(self, from_cache):
"""Read data from the cluster."""
- await self.get_attribute_value("battery_size", from_cache=from_cache)
- await self.get_attribute_value(
- "battery_percentage_remaining", from_cache=from_cache
- )
- await self.get_attribute_value("battery_voltage", from_cache=from_cache)
- await self.get_attribute_value("battery_quantity", from_cache=from_cache)
+ attributes = [
+ "battery_size",
+ "battery_percentage_remaining",
+ "battery_voltage",
+ "battery_quantity",
+ ]
+ await self.get_attributes(attributes, from_cache=from_cache)
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.PowerProfile.cluster_id)
@@ -398,7 +437,7 @@ class RSSILocation(ZigbeeChannel):
pass
-@registries.OUTPUT_CHANNEL_ONLY_CLUSTERS.register(general.Scenes.cluster_id)
+@registries.EVENT_RELAY_CLUSTERS.register(general.Scenes.cluster_id)
@registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Scenes.cluster_id)
class Scenes(ZigbeeChannel):
"""Scenes channel."""
diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py
index d9d8f57eaaf..1df7cf117e2 100644
--- a/homeassistant/components/zha/core/channels/homeautomation.py
+++ b/homeassistant/components/zha/core/channels/homeautomation.py
@@ -1,23 +1,16 @@
-"""
-Home automation channels module for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Home automation channels module for Zigbee Home Automation."""
import logging
from typing import Optional
import zigpy.zcl.clusters.homeautomation as homeautomation
-from homeassistant.helpers.dispatcher import async_dispatcher_send
-
-from . import AttributeListeningChannel, ZigbeeChannel
-from .. import registries
+from .. import registries, typing as zha_typing
from ..const import (
CHANNEL_ELECTRICAL_MEASUREMENT,
REPORT_CONFIG_DEFAULT,
SIGNAL_ATTR_UPDATED,
)
+from .base import ZigbeeChannel
_LOGGER = logging.getLogger(__name__)
@@ -59,16 +52,18 @@ class Diagnostic(ZigbeeChannel):
@registries.ZIGBEE_CHANNEL_REGISTRY.register(
homeautomation.ElectricalMeasurement.cluster_id
)
-class ElectricalMeasurementChannel(AttributeListeningChannel):
+class ElectricalMeasurementChannel(ZigbeeChannel):
"""Channel that polls active power level."""
CHANNEL_NAME = CHANNEL_ELECTRICAL_MEASUREMENT
REPORT_CONFIG = ({"attr": "active_power", "config": REPORT_CONFIG_DEFAULT},)
- def __init__(self, cluster, device):
+ def __init__(
+ self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType
+ ) -> None:
"""Initialize Metering."""
- super().__init__(cluster, device)
+ super().__init__(cluster, ch_pool)
self._divisor = None
self._multiplier = None
@@ -78,9 +73,13 @@ class ElectricalMeasurementChannel(AttributeListeningChannel):
# This is a polling channel. Don't allow cache.
result = await self.get_attribute_value("active_power", from_cache=False)
- async_dispatcher_send(
- self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result
- )
+ if result is not None:
+ self.async_send_signal(
+ f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}",
+ 0x050B,
+ "active_power",
+ result,
+ )
async def async_initialize(self, from_cache):
"""Initialize channel."""
@@ -97,6 +96,8 @@ class ElectricalMeasurementChannel(AttributeListeningChannel):
divisor = await self.get_attribute_value(
"power_divisor", from_cache=from_cache
)
+ if divisor is None:
+ divisor = 1
self._divisor = divisor
mult = await self.get_attribute_value(
@@ -106,6 +107,8 @@ class ElectricalMeasurementChannel(AttributeListeningChannel):
mult = await self.get_attribute_value(
"power_multiplier", from_cache=from_cache
)
+ if mult is None:
+ mult = 1
self._multiplier = mult
@property
diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py
index db4745d51c3..3c00e186ebb 100644
--- a/homeassistant/components/zha/core/channels/hvac.py
+++ b/homeassistant/components/zha/core/channels/hvac.py
@@ -1,20 +1,14 @@
-"""
-HVAC channels module for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""HVAC channels module for Zigbee Home Automation."""
import logging
from zigpy.exceptions import DeliveryError
import zigpy.zcl.clusters.hvac as hvac
from homeassistant.core import callback
-from homeassistant.helpers.dispatcher import async_dispatcher_send
-from . import ZigbeeChannel
from .. import registries
from ..const import REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED
+from .base import ZigbeeChannel
_LOGGER = logging.getLogger(__name__)
@@ -46,10 +40,10 @@ class FanChannel(ZigbeeChannel):
async def async_update(self):
"""Retrieve latest state."""
result = await self.get_attribute_value("fan_mode", from_cache=True)
-
- async_dispatcher_send(
- self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result
- )
+ if result is not None:
+ self.async_send_signal(
+ f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "fan_mode", result
+ )
@callback
def attribute_updated(self, attrid, value):
@@ -59,8 +53,8 @@ class FanChannel(ZigbeeChannel):
"Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value
)
if attrid == self._value_attribute:
- async_dispatcher_send(
- self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value
+ self.async_send_signal(
+ f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value
)
async def async_initialize(self, from_cache):
diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py
index 272fa28905c..7dc98d04515 100644
--- a/homeassistant/components/zha/core/channels/lighting.py
+++ b/homeassistant/components/zha/core/channels/lighting.py
@@ -1,16 +1,11 @@
-"""
-Lighting channels module for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Lighting channels module for Zigbee Home Automation."""
import logging
import zigpy.zcl.clusters.lighting as lighting
-from . import ZigbeeChannel
-from .. import registries
+from .. import registries, typing as zha_typing
from ..const import REPORT_CONFIG_DEFAULT
+from .base import ZigbeeChannel
_LOGGER = logging.getLogger(__name__)
@@ -38,9 +33,11 @@ class ColorChannel(ZigbeeChannel):
{"attr": "color_temperature", "config": REPORT_CONFIG_DEFAULT},
)
- def __init__(self, cluster, device):
+ def __init__(
+ self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType
+ ) -> None:
"""Initialize ColorChannel."""
- super().__init__(cluster, device)
+ super().__init__(cluster, ch_pool)
self._color_capabilities = None
def get_color_capabilities(self):
@@ -55,9 +52,8 @@ class ColorChannel(ZigbeeChannel):
async def async_initialize(self, from_cache):
"""Initialize channel."""
await self.fetch_color_capabilities(True)
- await self.get_attribute_value("color_temperature", from_cache=from_cache)
- await self.get_attribute_value("current_x", from_cache=from_cache)
- await self.get_attribute_value("current_y", from_cache=from_cache)
+ attributes = ["color_temperature", "current_x", "current_y"]
+ await self.get_attributes(attributes, from_cache=from_cache)
async def fetch_color_capabilities(self, from_cache):
"""Get the color configuration."""
@@ -75,7 +71,7 @@ class ColorChannel(ZigbeeChannel):
"color_temperature", from_cache=from_cache
)
- if result is not self.UNSUPPORTED_ATTRIBUTE:
+ if result is not None and result is not self.UNSUPPORTED_ATTRIBUTE:
capabilities |= self.CAPABILITIES_COLOR_TEMP
self._color_capabilities = capabilities
await super().async_initialize(from_cache)
diff --git a/homeassistant/components/zha/core/channels/lightlink.py b/homeassistant/components/zha/core/channels/lightlink.py
index 7cd2134988d..af0248c9713 100644
--- a/homeassistant/components/zha/core/channels/lightlink.py
+++ b/homeassistant/components/zha/core/channels/lightlink.py
@@ -1,15 +1,10 @@
-"""
-Lightlink channels module for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Lightlink channels module for Zigbee Home Automation."""
import logging
import zigpy.zcl.clusters.lightlink as lightlink
-from . import ZigbeeChannel
from .. import registries
+from .base import ZigbeeChannel
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py
index 39f45f6c4a2..208bc8f8836 100644
--- a/homeassistant/components/zha/core/channels/manufacturerspecific.py
+++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py
@@ -1,28 +1,26 @@
-"""
-Manufacturer specific channels module for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Manufacturer specific channels module for Zigbee Home Automation."""
import logging
from homeassistant.core import callback
-from homeassistant.helpers.dispatcher import async_dispatcher_send
-from . import AttributeListeningChannel, ZigbeeChannel
from .. import registries
from ..const import (
+ ATTR_ATTRIBUTE_ID,
+ ATTR_ATTRIBUTE_NAME,
+ ATTR_VALUE,
REPORT_CONFIG_ASAP,
REPORT_CONFIG_MAX_INT,
REPORT_CONFIG_MIN_INT,
SIGNAL_ATTR_UPDATED,
+ UNKNOWN,
)
+from .base import ZigbeeChannel
_LOGGER = logging.getLogger(__name__)
@registries.ZIGBEE_CHANNEL_REGISTRY.register(registries.SMARTTHINGS_HUMIDITY_CLUSTER)
-class SmartThingsHumidity(AttributeListeningChannel):
+class SmartThingsHumidity(ZigbeeChannel):
"""Smart Things Humidity channel."""
REPORT_CONFIG = [
@@ -41,10 +39,18 @@ class OsramButton(ZigbeeChannel):
REPORT_CONFIG = []
+@registries.CHANNEL_ONLY_CLUSTERS.register(0xFCC0)
+@registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFCC0)
+class OppleRemote(ZigbeeChannel):
+ """Opple button channel."""
+
+ REPORT_CONFIG = []
+
+
@registries.ZIGBEE_CHANNEL_REGISTRY.register(
registries.SMARTTHINGS_ACCELERATION_CLUSTER
)
-class SmartThingsAcceleration(AttributeListeningChannel):
+class SmartThingsAcceleration(ZigbeeChannel):
"""Smart Things Acceleration channel."""
REPORT_CONFIG = [
@@ -58,18 +64,19 @@ class SmartThingsAcceleration(AttributeListeningChannel):
def attribute_updated(self, attrid, value):
"""Handle attribute updates on this cluster."""
if attrid == self.value_attribute:
- async_dispatcher_send(
- self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value
- )
- else:
- self.zha_send_event(
- self._cluster,
- SIGNAL_ATTR_UPDATED,
- {
- "attribute_id": attrid,
- "attribute_name": self._cluster.attributes.get(attrid, ["Unknown"])[
- 0
- ],
- "value": value,
- },
+ self.async_send_signal(
+ f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}",
+ attrid,
+ self._cluster.attributes.get(attrid, [UNKNOWN])[0],
+ value,
)
+ return
+
+ self.zha_send_event(
+ SIGNAL_ATTR_UPDATED,
+ {
+ ATTR_ATTRIBUTE_ID: attrid,
+ ATTR_ATTRIBUTE_NAME: self._cluster.attributes.get(attrid, [UNKNOWN])[0],
+ ATTR_VALUE: value,
+ },
+ )
diff --git a/homeassistant/components/zha/core/channels/measurement.py b/homeassistant/components/zha/core/channels/measurement.py
index 369ecb69aa1..f05177de600 100644
--- a/homeassistant/components/zha/core/channels/measurement.py
+++ b/homeassistant/components/zha/core/channels/measurement.py
@@ -1,14 +1,8 @@
-"""
-Measurement channels module for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Measurement channels module for Zigbee Home Automation."""
import logging
import zigpy.zcl.clusters.measurement as measurement
-from . import AttributeListeningChannel
from .. import registries
from ..const import (
REPORT_CONFIG_DEFAULT,
@@ -16,12 +10,13 @@ from ..const import (
REPORT_CONFIG_MAX_INT,
REPORT_CONFIG_MIN_INT,
)
+from .base import ZigbeeChannel
_LOGGER = logging.getLogger(__name__)
@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.FlowMeasurement.cluster_id)
-class FlowMeasurement(AttributeListeningChannel):
+class FlowMeasurement(ZigbeeChannel):
"""Flow Measurement channel."""
REPORT_CONFIG = [{"attr": "measured_value", "config": REPORT_CONFIG_DEFAULT}]
@@ -30,7 +25,7 @@ class FlowMeasurement(AttributeListeningChannel):
@registries.ZIGBEE_CHANNEL_REGISTRY.register(
measurement.IlluminanceLevelSensing.cluster_id
)
-class IlluminanceLevelSensing(AttributeListeningChannel):
+class IlluminanceLevelSensing(ZigbeeChannel):
"""Illuminance Level Sensing channel."""
REPORT_CONFIG = [{"attr": "level_status", "config": REPORT_CONFIG_DEFAULT}]
@@ -39,7 +34,7 @@ class IlluminanceLevelSensing(AttributeListeningChannel):
@registries.ZIGBEE_CHANNEL_REGISTRY.register(
measurement.IlluminanceMeasurement.cluster_id
)
-class IlluminanceMeasurement(AttributeListeningChannel):
+class IlluminanceMeasurement(ZigbeeChannel):
"""Illuminance Measurement channel."""
REPORT_CONFIG = [{"attr": "measured_value", "config": REPORT_CONFIG_DEFAULT}]
@@ -47,21 +42,21 @@ class IlluminanceMeasurement(AttributeListeningChannel):
@registries.BINARY_SENSOR_CLUSTERS.register(measurement.OccupancySensing.cluster_id)
@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.OccupancySensing.cluster_id)
-class OccupancySensing(AttributeListeningChannel):
+class OccupancySensing(ZigbeeChannel):
"""Occupancy Sensing channel."""
REPORT_CONFIG = [{"attr": "occupancy", "config": REPORT_CONFIG_IMMEDIATE}]
@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.PressureMeasurement.cluster_id)
-class PressureMeasurement(AttributeListeningChannel):
+class PressureMeasurement(ZigbeeChannel):
"""Pressure measurement channel."""
REPORT_CONFIG = [{"attr": "measured_value", "config": REPORT_CONFIG_DEFAULT}]
@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.RelativeHumidity.cluster_id)
-class RelativeHumidity(AttributeListeningChannel):
+class RelativeHumidity(ZigbeeChannel):
"""Relative Humidity measurement channel."""
REPORT_CONFIG = [
@@ -75,7 +70,7 @@ class RelativeHumidity(AttributeListeningChannel):
@registries.ZIGBEE_CHANNEL_REGISTRY.register(
measurement.TemperatureMeasurement.cluster_id
)
-class TemperatureMeasurement(AttributeListeningChannel):
+class TemperatureMeasurement(ZigbeeChannel):
"""Temperature measurement channel."""
REPORT_CONFIG = [
diff --git a/homeassistant/components/zha/core/channels/protocol.py b/homeassistant/components/zha/core/channels/protocol.py
index aa463392e55..db7488e9a7f 100644
--- a/homeassistant/components/zha/core/channels/protocol.py
+++ b/homeassistant/components/zha/core/channels/protocol.py
@@ -1,15 +1,10 @@
-"""
-Protocol channels module for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Protocol channels module for Zigbee Home Automation."""
import logging
import zigpy.zcl.clusters.protocol as protocol
from .. import registries
-from ..channels import ZigbeeChannel
+from .base import ZigbeeChannel
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py
index 781738fc048..2616161de03 100644
--- a/homeassistant/components/zha/core/channels/security.py
+++ b/homeassistant/components/zha/core/channels/security.py
@@ -4,18 +4,16 @@ Security channels module for Zigbee Home Automation.
For more details about this component, please refer to the documentation at
https://home-assistant.io/integrations/zha/
"""
+import asyncio
import logging
from zigpy.exceptions import DeliveryError
import zigpy.zcl.clusters.security as security
from homeassistant.core import callback
-from homeassistant.helpers.dispatcher import async_dispatcher_send
-from . import ZigbeeChannel
from .. import registries
from ..const import (
- CLUSTER_COMMAND_SERVER,
SIGNAL_ATTR_UPDATED,
WARNING_DEVICE_MODE_EMERGENCY,
WARNING_DEVICE_SOUND_HIGH,
@@ -23,6 +21,7 @@ from ..const import (
WARNING_DEVICE_STROBE_HIGH,
WARNING_DEVICE_STROBE_YES,
)
+from .base import ZigbeeChannel
_LOGGER = logging.getLogger(__name__)
@@ -75,13 +74,7 @@ class IasWd(ZigbeeChannel):
value = IasWd.set_bit(value, 6, mode, 2)
value = IasWd.set_bit(value, 7, mode, 3)
- await self.device.issue_cluster_command(
- self.cluster.endpoint.endpoint_id,
- self.cluster.cluster_id,
- 0x0001,
- CLUSTER_COMMAND_SERVER,
- [value],
- )
+ await self.squawk(value)
async def start_warning(
self,
@@ -116,12 +109,8 @@ class IasWd(ZigbeeChannel):
value = IasWd.set_bit(value, 6, mode, 2)
value = IasWd.set_bit(value, 7, mode, 3)
- await self.device.issue_cluster_command(
- self.cluster.endpoint.endpoint_id,
- self.cluster.cluster_id,
- 0x0000,
- CLUSTER_COMMAND_SERVER,
- [value, warning_duration, strobe_duty_cycle, strobe_intensity],
+ await self.start_warning(
+ value, warning_duration, strobe_duty_cycle, strobe_intensity
)
@@ -135,18 +124,19 @@ class IASZoneChannel(ZigbeeChannel):
"""Handle commands received to this cluster."""
if command_id == 0:
state = args[0] & 3
- async_dispatcher_send(
- self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", state
+ self.async_send_signal(
+ f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 2, "zone_status", state
)
self.debug("Updated alarm state: %s", state)
elif command_id == 1:
self.debug("Enroll requested")
res = self._cluster.enroll_response(0, 0)
- self._zha_device.hass.async_create_task(res)
+ asyncio.create_task(res)
async def async_configure(self):
"""Configure IAS device."""
- if self._zha_device.skip_configuration:
+ await self.get_attribute_value("zone_type", from_cache=False)
+ if self._ch_pool.skip_configuration:
self.debug("skipping IASZoneChannel configuration")
return
@@ -172,19 +162,20 @@ class IASZoneChannel(ZigbeeChannel):
)
self.debug("finished IASZoneChannel configuration")
- await self.get_attribute_value("zone_type", from_cache=False)
-
@callback
def attribute_updated(self, attrid, value):
"""Handle attribute updates on this cluster."""
if attrid == 2:
value = value & 3
- async_dispatcher_send(
- self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value
+ self.async_send_signal(
+ f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}",
+ attrid,
+ self.cluster.attributes.get(attrid, [attrid])[0],
+ value,
)
async def async_initialize(self, from_cache):
"""Initialize channel."""
- await self.get_attribute_value("zone_status", from_cache=from_cache)
- await self.get_attribute_value("zone_state", from_cache=from_cache)
+ attributes = ["zone_status", "zone_state"]
+ await self.get_attributes(attributes, from_cache=from_cache)
await super().async_initialize(from_cache)
diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py
index c7de2943691..86533662838 100644
--- a/homeassistant/components/zha/core/channels/smartenergy.py
+++ b/homeassistant/components/zha/core/channels/smartenergy.py
@@ -1,18 +1,14 @@
-"""
-Smart energy channels module for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Smart energy channels module for Zigbee Home Automation."""
import logging
import zigpy.zcl.clusters.smartenergy as smartenergy
+from homeassistant.const import TIME_HOURS, TIME_SECONDS
from homeassistant.core import callback
-from .. import registries
-from ..channels import AttributeListeningChannel, ZigbeeChannel
+from .. import registries, typing as zha_typing
from ..const import REPORT_CONFIG_DEFAULT
+from .base import ZigbeeChannel
_LOGGER = logging.getLogger(__name__)
@@ -74,30 +70,32 @@ class Messaging(ZigbeeChannel):
@registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Metering.cluster_id)
-class Metering(AttributeListeningChannel):
+class Metering(ZigbeeChannel):
"""Metering channel."""
REPORT_CONFIG = [{"attr": "instantaneous_demand", "config": REPORT_CONFIG_DEFAULT}]
unit_of_measure_map = {
0x00: "kW",
- 0x01: "m³/h",
- 0x02: "ft³/h",
- 0x03: "ccf/h",
- 0x04: "US gal/h",
- 0x05: "IMP gal/h",
- 0x06: "BTU/h",
- 0x07: "l/h",
+ 0x01: f"m³/{TIME_HOURS}",
+ 0x02: f"ft³/{TIME_HOURS}",
+ 0x03: f"ccf/{TIME_HOURS}",
+ 0x04: f"US gal/{TIME_HOURS}",
+ 0x05: f"IMP gal/{TIME_HOURS}",
+ 0x06: f"BTU/{TIME_HOURS}",
+ 0x07: f"l/{TIME_HOURS}",
0x08: "kPa",
0x09: "kPa",
- 0x0A: "mcf/h",
+ 0x0A: f"mcf/{TIME_HOURS}",
0x0B: "unitless",
- 0x0C: "MJ/s",
+ 0x0C: f"MJ/{TIME_SECONDS}",
}
- def __init__(self, cluster, device):
+ def __init__(
+ self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType
+ ) -> None:
"""Initialize Metering."""
- super().__init__(cluster, device)
+ super().__init__(cluster, ch_pool)
self._divisor = None
self._multiplier = None
self._unit_enum = None
diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py
index f4cccfa4e52..4b5a5a0c6a1 100644
--- a/homeassistant/components/zha/core/const.py
+++ b/homeassistant/components/zha/core/const.py
@@ -13,11 +13,14 @@ from homeassistant.components.switch import DOMAIN as SWITCH
ATTR_ARGS = "args"
ATTR_ATTRIBUTE = "attribute"
+ATTR_ATTRIBUTE_ID = "attribute_id"
+ATTR_ATTRIBUTE_NAME = "attribute_name"
ATTR_AVAILABLE = "available"
ATTR_CLUSTER_ID = "cluster_id"
ATTR_CLUSTER_TYPE = "cluster_type"
ATTR_COMMAND = "command"
ATTR_COMMAND_TYPE = "command_type"
+ATTR_DEVICE_IEEE = "device_ieee"
ATTR_DEVICE_TYPE = "device_type"
ATTR_ENDPOINT_ID = "endpoint_id"
ATTR_IEEE = "ieee"
@@ -36,6 +39,7 @@ ATTR_QUIRK_CLASS = "quirk_class"
ATTR_RSSI = "rssi"
ATTR_SIGNATURE = "signature"
ATTR_TYPE = "type"
+ATTR_UNIQUE_ID = "unique_id"
ATTR_VALUE = "value"
ATTR_WARNING_DEVICE_DURATION = "duration"
ATTR_WARNING_DEVICE_MODE = "mode"
@@ -47,6 +51,7 @@ BAUD_RATES = [2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 2560
BINDINGS = "bindings"
CHANNEL_ACCELEROMETER = "accelerometer"
+CHANNEL_ANALOG_INPUT = "analog_input"
CHANNEL_ATTRIBUTE = "attribute"
CHANNEL_BASIC = "basic"
CHANNEL_COLOR = "light_color"
@@ -57,6 +62,7 @@ CHANNEL_EVENT_RELAY = "event_relay"
CHANNEL_FAN = "fan"
CHANNEL_HUMIDITY = "humidity"
CHANNEL_IAS_WD = "ias_wd"
+CHANNEL_IDENTIFY = "identify"
CHANNEL_ILLUMINANCE = "illuminance"
CHANNEL_LEVEL = ATTR_LEVEL
CHANNEL_MULTISTATE_INPUT = "multistate_input"
@@ -92,6 +98,7 @@ DATA_ZHA_BRIDGE_ID = "zha_bridge_id"
DATA_ZHA_CORE_EVENTS = "zha_core_events"
DATA_ZHA_DISPATCHERS = "zha_dispatchers"
DATA_ZHA_GATEWAY = "zha_gateway"
+DATA_ZHA_PLATFORM_LOADED = "platform_loaded"
DEBUG_COMP_BELLOWS = "bellows"
DEBUG_COMP_ZHA = "homeassistant.components.zha"
@@ -192,6 +199,7 @@ SENSOR_PRESSURE = CHANNEL_PRESSURE
SENSOR_TEMPERATURE = CHANNEL_TEMPERATURE
SENSOR_TYPE = "sensor_type"
+SIGNAL_ADD_ENTITIES = "zha_add_new_entities"
SIGNAL_ATTR_UPDATED = "attribute_updated"
SIGNAL_AVAILABLE = "available"
SIGNAL_MOVE_LEVEL = "move_level"
@@ -243,3 +251,9 @@ ZHA_GW_MSG_LOG_OUTPUT = "log_output"
ZHA_GW_MSG_RAW_INIT = "raw_device_initialized"
ZHA_GW_RADIO = "radio"
ZHA_GW_RADIO_DESCRIPTION = "radio_description"
+
+EFFECT_BLINK = 0x00
+EFFECT_BREATHE = 0x01
+EFFECT_OKAY = 0x02
+
+EFFECT_DEFAULT_VARIANT = 0x00
diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py
index 2e7c48c639f..f2544b43882 100644
--- a/homeassistant/components/zha/core/device.py
+++ b/homeassistant/components/zha/core/device.py
@@ -1,13 +1,9 @@
-"""
-Device for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Device for Zigbee Home Automation."""
import asyncio
from datetime import timedelta
from enum import Enum
import logging
+import random
import time
from zigpy import types
@@ -23,8 +19,9 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.typing import HomeAssistantType
-from .channels import EventRelayChannel
+from . import channels, typing as zha_typing
from .const import (
ATTR_ARGS,
ATTR_ATTRIBUTE,
@@ -47,14 +44,13 @@ from .const import (
ATTR_QUIRK_CLASS,
ATTR_RSSI,
ATTR_VALUE,
- CHANNEL_BASIC,
- CHANNEL_POWER_CONFIGURATION,
- CHANNEL_ZDO,
CLUSTER_COMMAND_SERVER,
CLUSTER_COMMANDS_CLIENT,
CLUSTER_COMMANDS_SERVER,
CLUSTER_TYPE_IN,
CLUSTER_TYPE_OUT,
+ EFFECT_DEFAULT_VARIANT,
+ EFFECT_OKAY,
POWER_BATTERY_OR_UNKNOWN,
POWER_MAINS_POWERED,
SIGNAL_AVAILABLE,
@@ -65,8 +61,9 @@ from .const import (
from .helpers import LogMixin
_LOGGER = logging.getLogger(__name__)
-_KEEP_ALIVE_INTERVAL = 7200
-_UPDATE_ALIVE_INTERVAL = timedelta(seconds=60)
+_CONSIDER_UNAVAILABLE_MAINS = 60 * 60 * 2 # 2 hours
+_CONSIDER_UNAVAILABLE_BATTERY = 60 * 60 * 6 # 6 hours
+_UPDATE_ALIVE_INTERVAL = (60, 90)
_CHECKIN_GRACE_PERIODS = 2
@@ -80,19 +77,21 @@ class DeviceStatus(Enum):
class ZHADevice(LogMixin):
"""ZHA Zigbee device object."""
- def __init__(self, hass, zigpy_device, zha_gateway):
+ def __init__(
+ self,
+ hass: HomeAssistantType,
+ zigpy_device: zha_typing.ZigpyDeviceType,
+ zha_gateway: zha_typing.ZhaGatewayType,
+ ):
"""Initialize the gateway."""
self.hass = hass
self._zigpy_device = zigpy_device
self._zha_gateway = zha_gateway
- self.cluster_channels = {}
- self._relay_channels = {}
- self._all_channels = []
self._available = False
self._available_signal = "{}_{}_{}".format(
self.name, self.ieee, SIGNAL_AVAILABLE
)
- self._checkins_missed_count = 2
+ self._checkins_missed_count = 0
self._unsub = async_dispatcher_connect(
self.hass, self._available_signal, self.async_initialize
)
@@ -101,11 +100,17 @@ class ZHADevice(LogMixin):
self._zigpy_device.__class__.__module__,
self._zigpy_device.__class__.__name__,
)
- self._available_check = async_track_time_interval(
- self.hass, self._check_available, _UPDATE_ALIVE_INTERVAL
+ if self.is_mains_powered:
+ self._consider_unavailable_time = _CONSIDER_UNAVAILABLE_MAINS
+ 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._ha_device_id = None
self.status = DeviceStatus.CREATED
+ self._channels = channels.Channels(self)
@property
def device_id(self):
@@ -116,6 +121,22 @@ class ZHADevice(LogMixin):
"""Set the HA device registry device id."""
self._ha_device_id = device_id
+ @property
+ def device(self) -> zha_typing.ZigpyDeviceType:
+ """Return underlying Zigpy device."""
+ return self._zigpy_device
+
+ @property
+ def channels(self) -> zha_typing.ChannelsType:
+ """Return ZHA channels."""
+ return self._channels
+
+ @channels.setter
+ def channels(self, value: zha_typing.ChannelsType) -> None:
+ """Channels setter."""
+ assert isinstance(value, channels.Channels)
+ self._channels = value
+
@property
def name(self):
"""Return device name."""
@@ -223,11 +244,6 @@ class ZHADevice(LogMixin):
"""Return the gateway for this device."""
return self._zha_gateway
- @property
- def all_channels(self):
- """Return cluster channels and relay channels for device."""
- return self._all_channels
-
@property
def device_automation_triggers(self):
"""Return the device automation triggers for this device."""
@@ -249,32 +265,53 @@ class ZHADevice(LogMixin):
"""Set availability from restore and prevent signals."""
self._available = available
- def _check_available(self, *_):
+ @classmethod
+ def new(
+ cls,
+ hass: HomeAssistantType,
+ zigpy_dev: zha_typing.ZigpyDeviceType,
+ gateway: zha_typing.ZhaGatewayType,
+ restored: bool = False,
+ ):
+ """Create new device."""
+ zha_dev = cls(hass, zigpy_dev, gateway)
+ zha_dev.channels = channels.Channels.new(zha_dev)
+ return zha_dev
+
+ async def _check_available(self, *_):
if self.last_seen is None:
self.update_available(False)
- else:
- difference = time.time() - self.last_seen
- if difference > _KEEP_ALIVE_INTERVAL:
- if self._checkins_missed_count < _CHECKIN_GRACE_PERIODS:
- self._checkins_missed_count += 1
- if (
- CHANNEL_BASIC in self.cluster_channels
- and self.manufacturer != "LUMI"
- ):
- self.debug(
- "Attempting to checkin with device - missed checkins: %s",
- self._checkins_missed_count,
- )
- self.hass.async_create_task(
- self.cluster_channels[CHANNEL_BASIC].get_attribute_value(
- ATTR_MANUFACTURER, from_cache=False
- )
- )
- else:
- self.update_available(False)
- else:
- self.update_available(True)
- self._checkins_missed_count = 0
+ return
+
+ difference = time.time() - self.last_seen
+ if difference < self._consider_unavailable_time:
+ self.update_available(True)
+ self._checkins_missed_count = 0
+ return
+
+ if (
+ self._checkins_missed_count >= _CHECKIN_GRACE_PERIODS
+ or self.manufacturer == "LUMI"
+ or not self._channels.pools
+ ):
+ self.update_available(False)
+ return
+
+ self._checkins_missed_count += 1
+ self.debug(
+ "Attempting to checkin with device - missed checkins: %s",
+ self._checkins_missed_count,
+ )
+ try:
+ pool = self._channels.pools[0]
+ basic_ch = pool.all_channels[f"{pool.id}:0x0000"]
+ except KeyError:
+ self.debug("does not have a mandatory basic cluster")
+ self.update_available(False)
+ return
+ res = await basic_ch.get_attribute_value(ATTR_MANUFACTURER, from_cache=False)
+ if res is not None:
+ self._checkins_missed_count = 0
def update_available(self, available):
"""Set sensor availability."""
@@ -309,112 +346,32 @@ class ZHADevice(LogMixin):
ATTR_DEVICE_TYPE: self.device_type,
}
- def add_cluster_channel(self, cluster_channel):
- """Add cluster channel to device."""
- # only keep 1 power configuration channel
- if (
- cluster_channel.name is CHANNEL_POWER_CONFIGURATION
- and CHANNEL_POWER_CONFIGURATION in self.cluster_channels
- ):
- return
-
- if isinstance(cluster_channel, EventRelayChannel):
- self._relay_channels[cluster_channel.unique_id] = cluster_channel
- self._all_channels.append(cluster_channel)
- else:
- self.cluster_channels[cluster_channel.name] = cluster_channel
- self._all_channels.append(cluster_channel)
-
- def get_channels_to_configure(self):
- """Get a deduped list of channels for configuration.
-
- This goes through all channels and gets a unique list of channels to
- configure. It first assembles a unique list of channels that are part
- of entities while stashing relay channels off to the side. It then
- takse the stashed relay channels and adds them to the list of channels
- that will be returned if there isn't a channel in the list for that
- cluster already. This is done to ensure each cluster is only configured
- once.
- """
- channel_keys = []
- channels = []
- relay_channels = self._relay_channels.values()
-
- def get_key(channel):
- channel_key = "ZDO"
- if hasattr(channel.cluster, "cluster_id"):
- channel_key = "{}_{}".format(
- channel.cluster.endpoint.endpoint_id, channel.cluster.cluster_id
- )
- return channel_key
-
- # first we get all unique non event channels
- for channel in self.all_channels:
- c_key = get_key(channel)
- if c_key not in channel_keys and channel not in relay_channels:
- channel_keys.append(c_key)
- channels.append(channel)
-
- # now we get event channels that still need their cluster configured
- for channel in relay_channels:
- channel_key = get_key(channel)
- if channel_key not in channel_keys:
- channel_keys.append(channel_key)
- channels.append(channel)
- return channels
-
async def async_configure(self):
"""Configure the device."""
self.debug("started configuration")
- await self._execute_channel_tasks(
- self.get_channels_to_configure(), "async_configure"
- )
+ await self._channels.async_configure()
self.debug("completed configuration")
entry = self.gateway.zha_storage.async_create_or_update(self)
self.debug("stored in registry: %s", entry)
+ if self._channels.identify_ch is not None:
+ await self._channels.identify_ch.trigger_effect(
+ EFFECT_OKAY, EFFECT_DEFAULT_VARIANT
+ )
+
async def async_initialize(self, from_cache=False):
"""Initialize channels."""
self.debug("started initialization")
- await self._execute_channel_tasks(
- self.all_channels, "async_initialize", from_cache
- )
+ await self._channels.async_initialize(from_cache)
self.debug("power source: %s", self.power_source)
self.status = DeviceStatus.INITIALIZED
self.debug("completed initialization")
- async def _execute_channel_tasks(self, channels, task_name, *args):
- """Gather and execute a set of CHANNEL tasks."""
- channel_tasks = []
- semaphore = asyncio.Semaphore(3)
- zdo_task = None
- for channel in channels:
- if channel.name == CHANNEL_ZDO:
- if zdo_task is None: # We only want to do this once
- zdo_task = self._async_create_task(
- semaphore, channel, task_name, *args
- )
- else:
- channel_tasks.append(
- self._async_create_task(semaphore, channel, task_name, *args)
- )
- if zdo_task is not None:
- await zdo_task
- await asyncio.gather(*channel_tasks)
-
- async def _async_create_task(self, semaphore, channel, func_name, *args):
- """Configure a single channel on this device."""
- try:
- async with semaphore:
- await getattr(channel, func_name)(*args)
- channel.debug("channel: '%s' stage succeeded", func_name)
- except Exception as ex: # pylint: disable=broad-except
- channel.warning("channel: '%s' stage failed ex: %s", func_name, ex)
-
@callback
- def async_unsub_dispatcher(self):
- """Unsubscribe the dispatcher."""
+ def async_cleanup_handles(self) -> None:
+ """Unsubscribe the dispatchers and timers."""
self._unsub()
+ self._cancel_available_check()
@callback
def async_update_last_seen(self, last_seen):
diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py
index d128ed274c0..b60357cf9f3 100644
--- a/homeassistant/components/zha/core/discovery.py
+++ b/homeassistant/components/zha/core/discovery.py
@@ -1,273 +1,150 @@
-"""
-Device discovery functions for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Device discovery functions for Zigbee Home Automation."""
import logging
-
-import zigpy.profiles
-from zigpy.zcl.clusters.general import OnOff, PowerConfiguration
+from typing import Callable, List, Tuple
from homeassistant import const as ha_const
from homeassistant.core import callback
-from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.typing import HomeAssistantType
-from .channels import AttributeListeningChannel, EventRelayChannel, ZDOChannel
-from .const import COMPONENTS, CONF_DEVICE_CONFIG, DATA_ZHA, ZHA_DISCOVERY_NEW
-from .registries import (
- CHANNEL_ONLY_CLUSTERS,
- COMPONENT_CLUSTERS,
- DEVICE_CLASS,
- EVENT_RELAY_CLUSTERS,
- OUTPUT_CHANNEL_ONLY_CLUSTERS,
- REMOTE_DEVICE_TYPES,
- SINGLE_INPUT_CLUSTER_DEVICE_CLASS,
- SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS,
- ZIGBEE_CHANNEL_REGISTRY,
-)
+from . import const as zha_const, registries as zha_regs, typing as zha_typing
+from .channels import base
_LOGGER = logging.getLogger(__name__)
@callback
-def async_process_endpoint(
- hass,
- config,
- endpoint_id,
- endpoint,
- discovery_infos,
- device,
- zha_device,
- is_new_join,
-):
- """Process an endpoint on a zigpy device."""
- if endpoint_id == 0: # ZDO
- _async_create_cluster_channel(
- endpoint, zha_device, is_new_join, channel_class=ZDOChannel
- )
+async def async_add_entities(
+ _async_add_entities: Callable,
+ entities: List[
+ Tuple[
+ zha_typing.ZhaEntityType,
+ Tuple[str, zha_typing.ZhaDeviceType, List[zha_typing.ChannelType]],
+ ]
+ ],
+) -> None:
+ """Add entities helper."""
+ if not entities:
return
+ to_add = [ent_cls(*args) for ent_cls, args in entities]
+ _async_add_entities(to_add, update_before_add=True)
+ entities.clear()
- component = None
- profile_clusters = []
- device_key = f"{device.ieee}-{endpoint_id}"
- node_config = {}
- if CONF_DEVICE_CONFIG in config:
- node_config = config[CONF_DEVICE_CONFIG].get(device_key, {})
- if endpoint.profile_id in zigpy.profiles.PROFILES:
- if DEVICE_CLASS.get(endpoint.profile_id, {}).get(endpoint.device_type, None):
- profile_info = DEVICE_CLASS[endpoint.profile_id]
- component = profile_info[endpoint.device_type]
+class ProbeEndpoint:
+ """All discovered channels and entities of an endpoint."""
- if ha_const.CONF_TYPE in node_config:
- component = node_config[ha_const.CONF_TYPE]
+ def __init__(self):
+ """Initialize instance."""
+ self._device_configs = {}
- if component and component in COMPONENTS and component in COMPONENT_CLUSTERS:
- profile_clusters = COMPONENT_CLUSTERS[component]
- if profile_clusters:
- profile_match = _async_handle_profile_match(
- hass,
- endpoint,
- profile_clusters,
- zha_device,
- component,
- device_key,
- is_new_join,
+ @callback
+ def discover_entities(self, channel_pool: zha_typing.ChannelPoolType) -> None:
+ """Process an endpoint on a zigpy device."""
+ self.discover_by_device_type(channel_pool)
+ self.discover_by_cluster_id(channel_pool)
+
+ @callback
+ def discover_by_device_type(self, channel_pool: zha_typing.ChannelPoolType) -> None:
+ """Process an endpoint on a zigpy device."""
+
+ unique_id = channel_pool.unique_id
+
+ component = self._device_configs.get(unique_id, {}).get(ha_const.CONF_TYPE)
+ if component is None:
+ ep_profile_id = channel_pool.endpoint.profile_id
+ ep_device_type = channel_pool.endpoint.device_type
+ component = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type)
+
+ if component and component in zha_const.COMPONENTS:
+ channels = channel_pool.unclaimed_channels()
+ entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity(
+ component, channel_pool.manufacturer, channel_pool.model, channels
)
- discovery_infos.append(profile_match)
+ if entity_class is None:
+ return
+ channel_pool.claim_channels(claimed)
+ channel_pool.async_new_entity(component, entity_class, unique_id, claimed)
- discovery_infos.extend(
- _async_handle_single_cluster_matches(
- hass, endpoint, zha_device, profile_clusters, device_key, is_new_join
- )
- )
+ @callback
+ def discover_by_cluster_id(self, channel_pool: zha_typing.ChannelPoolType) -> None:
+ """Process an endpoint on a zigpy device."""
-
-@callback
-def _async_create_cluster_channel(
- cluster, zha_device, is_new_join, channels=None, channel_class=None
-):
- """Create a cluster channel and attach it to a device."""
- # really ugly hack to deal with xiaomi using the door lock cluster
- # incorrectly.
- if hasattr(cluster, "ep_attribute") and cluster.ep_attribute == "multistate_input":
- channel_class = AttributeListeningChannel
- # end of ugly hack
- if channel_class is None:
- channel_class = ZIGBEE_CHANNEL_REGISTRY.get(
- cluster.cluster_id, AttributeListeningChannel
- )
- channel = channel_class(cluster, zha_device)
- zha_device.add_cluster_channel(channel)
- if channels is not None:
- channels.append(channel)
-
-
-@callback
-def async_dispatch_discovery_info(hass, is_new_join, discovery_info):
- """Dispatch or store discovery information."""
- if not discovery_info["channels"]:
- _LOGGER.warning(
- "there are no channels in the discovery info: %s", discovery_info
- )
- return
- component = discovery_info["component"]
- if is_new_join:
- async_dispatcher_send(hass, ZHA_DISCOVERY_NEW.format(component), discovery_info)
- else:
- hass.data[DATA_ZHA][component][discovery_info["unique_id"]] = discovery_info
-
-
-@callback
-def _async_handle_profile_match(
- hass, endpoint, profile_clusters, zha_device, component, device_key, is_new_join
-):
- """Dispatch a profile match to the appropriate HA component."""
- in_clusters = [
- endpoint.in_clusters[c] for c in profile_clusters if c in endpoint.in_clusters
- ]
- out_clusters = [
- endpoint.out_clusters[c] for c in profile_clusters if c in endpoint.out_clusters
- ]
-
- channels = []
-
- for cluster in in_clusters:
- _async_create_cluster_channel(
- cluster, zha_device, is_new_join, channels=channels
- )
-
- for cluster in out_clusters:
- _async_create_cluster_channel(
- cluster, zha_device, is_new_join, channels=channels
- )
-
- discovery_info = {
- "unique_id": device_key,
- "zha_device": zha_device,
- "channels": channels,
- "component": component,
- }
-
- return discovery_info
-
-
-@callback
-def _async_handle_single_cluster_matches(
- hass, endpoint, zha_device, profile_clusters, device_key, is_new_join
-):
- """Dispatch single cluster matches to HA components."""
- cluster_matches = []
- cluster_match_results = []
- matched_power_configuration = False
- for cluster in endpoint.in_clusters.values():
- if cluster.cluster_id in CHANNEL_ONLY_CLUSTERS:
- cluster_match_results.append(
- _async_handle_channel_only_cluster_match(
- zha_device, cluster, is_new_join
- )
- )
- continue
-
- if cluster.cluster_id not in profile_clusters:
- # Only create one battery sensor per device
- if cluster.cluster_id == PowerConfiguration.cluster_id and (
- zha_device.is_mains_powered or matched_power_configuration
- ):
+ items = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.items()
+ single_input_clusters = {
+ cluster_class: match
+ for cluster_class, match in items
+ if not isinstance(cluster_class, int)
+ }
+ remaining_channels = channel_pool.unclaimed_channels()
+ for channel in remaining_channels:
+ if channel.cluster.cluster_id in zha_regs.CHANNEL_ONLY_CLUSTERS:
+ channel_pool.claim_channels([channel])
continue
- if (
- cluster.cluster_id == PowerConfiguration.cluster_id
- and not zha_device.is_mains_powered
- ):
- matched_power_configuration = True
-
- cluster_match_results.append(
- _async_handle_single_cluster_match(
- hass,
- zha_device,
- cluster,
- device_key,
- SINGLE_INPUT_CLUSTER_DEVICE_CLASS,
- is_new_join,
- )
+ component = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.get(
+ channel.cluster.cluster_id
)
+ if component is None:
+ for cluster_class, match in single_input_clusters.items():
+ if isinstance(channel.cluster, cluster_class):
+ component = match
+ break
- for cluster in endpoint.out_clusters.values():
- if cluster.cluster_id in OUTPUT_CHANNEL_ONLY_CLUSTERS:
- cluster_match_results.append(
- _async_handle_channel_only_cluster_match(
- zha_device, cluster, is_new_join
- )
+ self.probe_single_cluster(component, channel, channel_pool)
+
+ # until we can get rid off registries
+ self.handle_on_off_output_cluster_exception(channel_pool)
+
+ @staticmethod
+ def probe_single_cluster(
+ component: str,
+ channel: zha_typing.ChannelType,
+ ep_channels: zha_typing.ChannelPoolType,
+ ) -> None:
+ """Probe specified cluster for specific component."""
+ if component is None or component not in zha_const.COMPONENTS:
+ return
+ channel_list = [channel]
+ unique_id = f"{ep_channels.unique_id}-{channel.cluster.cluster_id}"
+
+ entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity(
+ component, ep_channels.manufacturer, ep_channels.model, channel_list
+ )
+ if entity_class is None:
+ return
+ ep_channels.claim_channels(claimed)
+ ep_channels.async_new_entity(component, entity_class, unique_id, claimed)
+
+ def handle_on_off_output_cluster_exception(
+ self, ep_channels: zha_typing.ChannelPoolType
+ ) -> None:
+ """Process output clusters of the endpoint."""
+
+ profile_id = ep_channels.endpoint.profile_id
+ device_type = ep_channels.endpoint.device_type
+ if device_type in zha_regs.REMOTE_DEVICE_TYPES.get(profile_id, []):
+ return
+
+ for cluster_id, cluster in ep_channels.endpoint.out_clusters.items():
+ component = zha_regs.SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.get(
+ cluster.cluster_id
)
- continue
+ if component is None:
+ continue
- device_type = cluster.endpoint.device_type
- profile_id = cluster.endpoint.profile_id
-
- if cluster.cluster_id not in profile_clusters:
- # prevent remotes and controllers from getting entities
- if not (
- cluster.cluster_id == OnOff.cluster_id
- and profile_id in REMOTE_DEVICE_TYPES
- and device_type in REMOTE_DEVICE_TYPES[profile_id]
- ):
- cluster_match_results.append(
- _async_handle_single_cluster_match(
- hass,
- zha_device,
- cluster,
- device_key,
- SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS,
- is_new_join,
- )
- )
-
- if cluster.cluster_id in EVENT_RELAY_CLUSTERS:
- _async_create_cluster_channel(
- cluster, zha_device, is_new_join, channel_class=EventRelayChannel
+ channel_class = zha_regs.ZIGBEE_CHANNEL_REGISTRY.get(
+ cluster_id, base.ZigbeeChannel
)
+ channel = channel_class(cluster, ep_channels)
+ self.probe_single_cluster(component, channel, ep_channels)
- for cluster_match in cluster_match_results:
- if cluster_match is not None:
- cluster_matches.append(cluster_match)
- return cluster_matches
+ def initialize(self, hass: HomeAssistantType) -> None:
+ """Update device overrides config."""
+ zha_config = hass.data[zha_const.DATA_ZHA].get(zha_const.DATA_ZHA_CONFIG, {})
+ overrides = zha_config.get(zha_const.CONF_DEVICE_CONFIG)
+ if overrides:
+ self._device_configs.update(overrides)
-@callback
-def _async_handle_channel_only_cluster_match(zha_device, cluster, is_new_join):
- """Handle a channel only cluster match."""
- _async_create_cluster_channel(cluster, zha_device, is_new_join)
-
-
-@callback
-def _async_handle_single_cluster_match(
- hass, zha_device, cluster, device_key, device_classes, is_new_join
-):
- """Dispatch a single cluster match to a HA component."""
- component = None # sub_component = None
- for cluster_type, candidate_component in device_classes.items():
- if isinstance(cluster_type, int):
- if cluster.cluster_id == cluster_type:
- component = candidate_component
- elif isinstance(cluster, cluster_type):
- component = candidate_component
- break
-
- if component is None or component not in COMPONENTS:
- return
- channels = []
- _async_create_cluster_channel(cluster, zha_device, is_new_join, channels=channels)
-
- cluster_key = f"{device_key}-{cluster.cluster_id}"
- discovery_info = {
- "unique_id": cluster_key,
- "zha_device": zha_device,
- "channels": channels,
- "entity_suffix": f"_{cluster.cluster_id}",
- "component": component,
- }
-
- return discovery_info
+PROBE = ProbeEndpoint()
diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py
index 33faaa334cb..6c0681d9eca 100644
--- a/homeassistant/components/zha/core/gateway.py
+++ b/homeassistant/components/zha/core/gateway.py
@@ -1,9 +1,4 @@
-"""
-Virtual gateway for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Virtual gateway for Zigbee Home Automation."""
import asyncio
import collections
@@ -23,6 +18,7 @@ from homeassistant.helpers.device_registry import (
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity_registry import async_get_registry as get_ent_reg
+from . import discovery, typing as zha_typing
from .const import (
ATTR_IEEE,
ATTR_MANUFACTURER,
@@ -38,6 +34,7 @@ from .const import (
DATA_ZHA,
DATA_ZHA_BRIDGE_ID,
DATA_ZHA_GATEWAY,
+ DATA_ZHA_PLATFORM_LOADED,
DEBUG_COMP_BELLOWS,
DEBUG_COMP_ZHA,
DEBUG_COMP_ZIGPY,
@@ -52,6 +49,7 @@ from .const import (
DEFAULT_BAUDRATE,
DEFAULT_DATABASE_NAME,
DOMAIN,
+ SIGNAL_ADD_ENTITIES,
SIGNAL_REMOVE,
UNKNOWN_MANUFACTURER,
UNKNOWN_MODEL,
@@ -72,7 +70,6 @@ from .const import (
ZHA_GW_RADIO_DESCRIPTION,
)
from .device import DeviceStatus, ZHADevice
-from .discovery import async_dispatch_discovery_info, async_process_endpoint
from .group import ZHAGroup
from .patches import apply_application_controller_patch
from .registries import RADIO_TYPES
@@ -112,6 +109,8 @@ class ZHAGateway:
async def async_initialize(self):
"""Initialize controller and connect radio."""
+ discovery.PROBE.initialize(self._hass)
+
self.zha_storage = await async_get_registry(self._hass)
self.ha_device_registry = await get_dev_reg(self._hass)
self.ha_entity_registry = await get_ent_reg(self._hass)
@@ -138,22 +137,34 @@ class ZHAGateway:
self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(
self.application_controller.ieee
)
+ self._initialize_groups()
+
+ async def async_load_devices(self) -> None:
+ """Restore ZHA devices from zigpy application state."""
+ await self._hass.data[DATA_ZHA][DATA_ZHA_PLATFORM_LOADED].wait()
- init_tasks = []
semaphore = asyncio.Semaphore(2)
- async def init_with_semaphore(coro, semaphore):
- """Don't flood the zigbee network during initialization."""
+ async def _throttle(device: zha_typing.ZigpyDeviceType):
async with semaphore:
- await coro
+ await self.async_device_restored(device)
- for device in self.application_controller.devices.values():
- init_tasks.append(
- init_with_semaphore(self.async_device_restored(device), semaphore)
- )
- await asyncio.gather(*init_tasks)
+ zigpy_devices = self.application_controller.devices.values()
+ _LOGGER.debug("Loading battery powered devices")
+ await asyncio.gather(
+ *[
+ _throttle(dev)
+ for dev in zigpy_devices
+ if not dev.node_desc.is_mains_powered
+ ]
+ )
+ async_dispatcher_send(self._hass, SIGNAL_ADD_ENTITIES)
- self._initialize_groups()
+ _LOGGER.debug("Loading mains powered devices")
+ await asyncio.gather(
+ *[_throttle(dev) for dev in zigpy_devices if dev.node_desc.is_mains_powered]
+ )
+ async_dispatcher_send(self._hass, SIGNAL_ADD_ENTITIES)
def device_joined(self, device):
"""Handle device joined.
@@ -224,7 +235,7 @@ class ZHAGateway:
def _send_group_gateway_message(self, zigpy_group, gateway_message_type):
"""Send the gareway event for a zigpy group event."""
- zha_group = self._groups.get(zigpy_group.group_id, None)
+ zha_group = self._groups.get(zigpy_group.group_id)
if zha_group is not None:
async_dispatcher_send(
self._hass,
@@ -251,7 +262,7 @@ class ZHAGateway:
entity_refs = self._device_registry.pop(device.ieee, None)
if zha_device is not None:
device_info = zha_device.async_get_info()
- zha_device.async_unsub_dispatcher()
+ zha_device.async_cleanup_handles()
async_dispatcher_send(
self._hass, "{}_{}".format(SIGNAL_REMOVE, str(zha_device.ieee))
)
@@ -361,11 +372,13 @@ class ZHAGateway:
self._async_get_or_create_group(group)
@callback
- def _async_get_or_create_device(self, zigpy_device):
+ def _async_get_or_create_device(
+ self, zigpy_device: zha_typing.ZigpyDeviceType, restored: bool = False
+ ):
"""Get or create a ZHA device."""
zha_device = self._devices.get(zigpy_device.ieee)
if zha_device is None:
- zha_device = ZHADevice(self._hass, zigpy_device, self)
+ zha_device = ZHADevice.new(self._hass, zigpy_device, self, restored)
self._devices[zigpy_device.ieee] = zha_device
device_registry_device = self.ha_device_registry.async_get_or_create(
config_entry_id=self._config_entry.entry_id,
@@ -411,13 +424,14 @@ class ZHAGateway:
self.zha_storage.async_update(device)
await self.zha_storage.async_save()
- async def async_device_initialized(self, device):
+ async def async_device_initialized(self, device: zha_typing.ZigpyDeviceType):
"""Handle device joined and basic information discovered (async)."""
zha_device = self._async_get_or_create_device(device)
_LOGGER.debug(
- "device - %s entering async_device_initialized - is_new_join: %s",
- f"0x{device.nwk:04x}:{device.ieee}",
+ "device - %s:%s entering async_device_initialized - is_new_join: %s",
+ device.nwk,
+ device.ieee,
zha_device.status is not DeviceStatus.INITIALIZED,
)
@@ -425,16 +439,18 @@ class ZHAGateway:
# ZHA already has an initialized device so either the device was assigned a
# new nwk or device was physically reset and added again without being removed
_LOGGER.debug(
- "device - %s has been reset and re-added or its nwk address changed",
- f"0x{device.nwk:04x}:{device.ieee}",
+ "device - %s:%s has been reset and re-added or its nwk address changed",
+ device.nwk,
+ device.ieee,
)
await self._async_device_rejoined(zha_device)
else:
_LOGGER.debug(
- "device - %s has joined the ZHA zigbee network",
- f"0x{device.nwk:04x}:{device.ieee}",
+ "device - %s:%s has joined the ZHA zigbee network",
+ device.nwk,
+ device.ieee,
)
- await self._async_device_joined(device, zha_device)
+ await self._async_device_joined(zha_device)
device_info = zha_device.async_get_info()
@@ -447,64 +463,36 @@ class ZHAGateway:
},
)
- async def _async_device_joined(self, device, zha_device):
- discovery_infos = []
- for endpoint_id, endpoint in device.endpoints.items():
- async_process_endpoint(
- self._hass,
- self._config,
- endpoint_id,
- endpoint,
- discovery_infos,
- device,
- zha_device,
- True,
- )
-
+ async def _async_device_joined(self, zha_device: zha_typing.ZhaDeviceType) -> None:
await zha_device.async_configure()
# will cause async_init to fire so don't explicitly call it
zha_device.update_available(True)
-
- for discovery_info in discovery_infos:
- async_dispatch_discovery_info(self._hass, True, discovery_info)
+ async_dispatcher_send(self._hass, SIGNAL_ADD_ENTITIES)
# only public for testing
- async def async_device_restored(self, device):
+ async def async_device_restored(self, device: zha_typing.ZigpyDeviceType):
"""Add an existing device to the ZHA zigbee network when ZHA first starts."""
- zha_device = self._async_get_or_create_device(device)
- discovery_infos = []
- for endpoint_id, endpoint in device.endpoints.items():
- async_process_endpoint(
- self._hass,
- self._config,
- endpoint_id,
- endpoint,
- discovery_infos,
- device,
- zha_device,
- False,
- )
+ zha_device = self._async_get_or_create_device(device, restored=True)
if zha_device.is_mains_powered:
# the device isn't a battery powered device so we should be able
# to update it now
_LOGGER.debug(
- "attempting to request fresh state for device - %s %s %s",
- f"0x{zha_device.nwk:04x}:{zha_device.ieee}",
+ "attempting to request fresh state for device - %s:%s %s with power source %s",
+ zha_device.nwk,
+ zha_device.ieee,
zha_device.name,
- f"with power source: {zha_device.power_source}",
+ zha_device.power_source,
)
await zha_device.async_initialize(from_cache=False)
else:
await zha_device.async_initialize(from_cache=True)
- for discovery_info in discovery_infos:
- async_dispatch_discovery_info(self._hass, False, discovery_info)
-
async def _async_device_rejoined(self, zha_device):
_LOGGER.debug(
- "skipping discovery for previously discovered device - %s",
- f"0x{zha_device.nwk:04x}:{zha_device.ieee}",
+ "skipping discovery for previously discovered device - %s:%s",
+ zha_device.nwk,
+ zha_device.ieee,
)
# we don't have to do this on a nwk swap but we don't have a way to tell currently
await zha_device.async_configure()
diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py
index 92ce1f75360..ca2cc0ff1d3 100644
--- a/homeassistant/components/zha/core/group.py
+++ b/homeassistant/components/zha/core/group.py
@@ -1,9 +1,4 @@
-"""
-Group for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Group for Zigbee Home Automation."""
import asyncio
import logging
diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py
index e3ff446ba98..ab4c7ae540c 100644
--- a/homeassistant/components/zha/core/helpers.py
+++ b/homeassistant/components/zha/core/helpers.py
@@ -1,9 +1,4 @@
-"""
-Helpers for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Helpers for Zigbee Home Automation."""
import collections
import logging
@@ -40,18 +35,6 @@ async def safe_read(
return {}
-def get_attr_id_by_name(cluster, attr_name):
- """Get the attribute id for a cluster attribute by its name."""
- return next(
- (
- attrid
- for attrid, (attrname, datatype) in cluster.attributes.items()
- if attr_name == attrname
- ),
- None,
- )
-
-
async def get_matched_clusters(source_zha_device, target_zha_device):
"""Get matched input/output cluster pairs for 2 devices."""
source_clusters = source_zha_device.async_get_std_clusters()
diff --git a/homeassistant/components/zha/core/patches.py b/homeassistant/components/zha/core/patches.py
index a4e84e83105..3d8c84e9bf3 100644
--- a/homeassistant/components/zha/core/patches.py
+++ b/homeassistant/components/zha/core/patches.py
@@ -1,9 +1,4 @@
-"""
-Patch functions for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Patch functions for Zigbee Home Automation."""
def apply_application_controller_patch(zha_gateway):
diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py
index 4f5c6fc5c6b..3b08d1acd37 100644
--- a/homeassistant/components/zha/core/registries.py
+++ b/homeassistant/components/zha/core/registries.py
@@ -1,11 +1,6 @@
-"""
-Mapping registries for Zigbee Home Automation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/integrations/zha/
-"""
+"""Mapping registries for Zigbee Home Automation."""
import collections
-from typing import Callable, Set, Union
+from typing import Callable, Dict, List, Set, Tuple, Union
import attr
import bellows.ezsp
@@ -32,9 +27,10 @@ from homeassistant.components.sensor import DOMAIN as SENSOR
from homeassistant.components.switch import DOMAIN as SWITCH
# importing channels updates registries
-from . import channels # noqa: F401 pylint: disable=unused-import
+from . import channels as zha_channels # noqa: F401 pylint: disable=unused-import
from .const import CONTROLLER, ZHA_GW_RADIO, ZHA_GW_RADIO_DESCRIPTION, RadioType
from .decorators import CALLABLE_T, DictRegistry, SetRegistry
+from .typing import ChannelType
SMARTTHINGS_ACCELERATION_CLUSTER = 0xFC02
SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE = 0x8000
@@ -62,30 +58,33 @@ REMOTE_DEVICE_TYPES = {
zigpy.profiles.zll.DeviceType.SCENE_CONTROLLER,
],
}
+REMOTE_DEVICE_TYPES = collections.defaultdict(list, REMOTE_DEVICE_TYPES)
SINGLE_INPUT_CLUSTER_DEVICE_CLASS = {
# this works for now but if we hit conflicts we can break it out to
# a different dict that is keyed by manufacturer
SMARTTHINGS_ACCELERATION_CLUSTER: BINARY_SENSOR,
SMARTTHINGS_HUMIDITY_CLUSTER: SENSOR,
- zcl.clusters.closures.DoorLock: LOCK,
- zcl.clusters.closures.WindowCovering: COVER,
+ zcl.clusters.closures.DoorLock.cluster_id: LOCK,
+ zcl.clusters.closures.WindowCovering.cluster_id: COVER,
zcl.clusters.general.AnalogInput.cluster_id: SENSOR,
zcl.clusters.general.MultistateInput.cluster_id: SENSOR,
- zcl.clusters.general.OnOff: SWITCH,
- zcl.clusters.general.PowerConfiguration: SENSOR,
- zcl.clusters.homeautomation.ElectricalMeasurement: SENSOR,
- zcl.clusters.hvac.Fan: FAN,
- zcl.clusters.measurement.IlluminanceMeasurement: SENSOR,
- zcl.clusters.measurement.OccupancySensing: BINARY_SENSOR,
- zcl.clusters.measurement.PressureMeasurement: SENSOR,
- zcl.clusters.measurement.RelativeHumidity: SENSOR,
- zcl.clusters.measurement.TemperatureMeasurement: SENSOR,
- zcl.clusters.security.IasZone: BINARY_SENSOR,
- zcl.clusters.smartenergy.Metering: SENSOR,
+ zcl.clusters.general.OnOff.cluster_id: SWITCH,
+ zcl.clusters.general.PowerConfiguration.cluster_id: SENSOR,
+ zcl.clusters.homeautomation.ElectricalMeasurement.cluster_id: SENSOR,
+ zcl.clusters.hvac.Fan.cluster_id: FAN,
+ zcl.clusters.measurement.IlluminanceMeasurement.cluster_id: SENSOR,
+ zcl.clusters.measurement.OccupancySensing.cluster_id: BINARY_SENSOR,
+ zcl.clusters.measurement.PressureMeasurement.cluster_id: SENSOR,
+ zcl.clusters.measurement.RelativeHumidity.cluster_id: SENSOR,
+ zcl.clusters.measurement.TemperatureMeasurement.cluster_id: SENSOR,
+ zcl.clusters.security.IasZone.cluster_id: BINARY_SENSOR,
+ zcl.clusters.smartenergy.Metering.cluster_id: SENSOR,
}
-SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = {zcl.clusters.general.OnOff: BINARY_SENSOR}
+SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = {
+ zcl.clusters.general.OnOff.cluster_id: BINARY_SENSOR
+}
SWITCH_CLUSTERS = SetRegistry()
@@ -94,7 +93,6 @@ BINARY_SENSOR_CLUSTERS.add(SMARTTHINGS_ACCELERATION_CLUSTER)
BINDABLE_CLUSTERS = SetRegistry()
CHANNEL_ONLY_CLUSTERS = SetRegistry()
-CLUSTER_REPORT_CONFIGS = {}
CUSTOM_CLUSTER_MAPPINGS = {}
DEVICE_CLASS = {
@@ -122,6 +120,7 @@ DEVICE_CLASS = {
zigpy.profiles.zll.DeviceType.ON_OFF_PLUGIN_UNIT: SWITCH,
},
}
+DEVICE_CLASS = collections.defaultdict(dict, DEVICE_CLASS)
DEVICE_TRACKER_CLUSTERS = SetRegistry()
EVENT_RELAY_CLUSTERS = SetRegistry()
@@ -193,6 +192,63 @@ class MatchRule:
models: Union[Callable, Set[str], str] = attr.ib(
factory=frozenset, converter=set_or_callable
)
+ aux_channels: Union[Callable, Set[str], str] = attr.ib(
+ factory=frozenset, converter=set_or_callable
+ )
+
+ def claim_channels(self, channel_pool: List[ChannelType]) -> List[ChannelType]:
+ """Return a list of channels this rule matches + aux channels."""
+ claimed = []
+ if isinstance(self.channel_names, frozenset):
+ claimed.extend([ch for ch in channel_pool if ch.name in self.channel_names])
+ if isinstance(self.generic_ids, frozenset):
+ claimed.extend(
+ [ch for ch in channel_pool if ch.generic_id in self.generic_ids]
+ )
+ if isinstance(self.aux_channels, frozenset):
+ claimed.extend([ch for ch in channel_pool if ch.name in self.aux_channels])
+ return claimed
+
+ def strict_matched(self, manufacturer: str, model: str, channels: List) -> bool:
+ """Return True if this device matches the criteria."""
+ return all(self._matched(manufacturer, model, channels))
+
+ def loose_matched(self, manufacturer: str, model: str, channels: List) -> bool:
+ """Return True if this device matches the criteria."""
+ return any(self._matched(manufacturer, model, channels))
+
+ def _matched(self, manufacturer: str, model: str, channels: List) -> list:
+ """Return a list of field matches."""
+ if not any(attr.asdict(self).values()):
+ return [False]
+
+ matches = []
+ if self.channel_names:
+ channel_names = {ch.name for ch in channels}
+ matches.append(self.channel_names.issubset(channel_names))
+
+ if self.generic_ids:
+ all_generic_ids = {ch.generic_id for ch in channels}
+ matches.append(self.generic_ids.issubset(all_generic_ids))
+
+ if self.manufacturers:
+ if callable(self.manufacturers):
+ matches.append(self.manufacturers(manufacturer))
+ else:
+ matches.append(manufacturer in self.manufacturers)
+
+ if self.models:
+ if callable(self.models):
+ matches.append(self.models(model))
+ else:
+ matches.append(model in self.models)
+
+ return matches
+
+
+RegistryDictType = Dict[
+ str, Dict[MatchRule, CALLABLE_T]
+] # pylint: disable=invalid-name
class ZHAEntityRegistry:
@@ -200,18 +256,24 @@ class ZHAEntityRegistry:
def __init__(self):
"""Initialize Registry instance."""
- self._strict_registry = collections.defaultdict(dict)
- self._loose_registry = collections.defaultdict(dict)
+ self._strict_registry: RegistryDictType = collections.defaultdict(dict)
+ self._loose_registry: RegistryDictType = collections.defaultdict(dict)
def get_entity(
- self, component: str, zha_device, chnls: dict, default: CALLABLE_T = None
- ) -> CALLABLE_T:
+ self,
+ component: str,
+ manufacturer: str,
+ model: str,
+ channels: List[ChannelType],
+ default: CALLABLE_T = None,
+ ) -> Tuple[CALLABLE_T, List[ChannelType]]:
"""Match a ZHA Channels to a ZHA Entity class."""
for match in self._strict_registry[component]:
- if self._strict_matched(zha_device, chnls, match):
- return self._strict_registry[component][match]
+ if match.strict_matched(manufacturer, model, channels):
+ claimed = match.claim_channels(channels)
+ return self._strict_registry[component][match], claimed
- return default
+ return default, []
def strict_match(
self,
@@ -220,10 +282,13 @@ class ZHAEntityRegistry:
generic_ids: Union[Callable, Set[str], str] = None,
manufacturers: Union[Callable, Set[str], str] = None,
models: Union[Callable, Set[str], str] = None,
+ aux_channels: Union[Callable, Set[str], str] = None,
) -> Callable[[CALLABLE_T], CALLABLE_T]:
"""Decorate a strict match rule."""
- rule = MatchRule(channel_names, generic_ids, manufacturers, models)
+ rule = MatchRule(
+ channel_names, generic_ids, manufacturers, models, aux_channels
+ )
def decorator(zha_ent: CALLABLE_T) -> CALLABLE_T:
"""Register a strict match rule.
@@ -242,10 +307,13 @@ class ZHAEntityRegistry:
generic_ids: Union[Callable, Set[str], str] = None,
manufacturers: Union[Callable, Set[str], str] = None,
models: Union[Callable, Set[str], str] = None,
+ aux_channels: Union[Callable, Set[str], str] = None,
) -> Callable[[CALLABLE_T], CALLABLE_T]:
"""Decorate a loose match rule."""
- rule = MatchRule(channel_names, generic_ids, manufacturers, models)
+ rule = MatchRule(
+ channel_names, generic_ids, manufacturers, models, aux_channels
+ )
def decorator(zha_entity: CALLABLE_T) -> CALLABLE_T:
"""Register a loose match rule.
@@ -257,42 +325,5 @@ class ZHAEntityRegistry:
return decorator
- def _strict_matched(self, zha_device, chnls: dict, rule: MatchRule) -> bool:
- """Return True if this device matches the criteria."""
- return all(self._matched(zha_device, chnls, rule))
-
- def _loose_matched(self, zha_device, chnls: dict, rule: MatchRule) -> bool:
- """Return True if this device matches the criteria."""
- return any(self._matched(zha_device, chnls, rule))
-
- @staticmethod
- def _matched(zha_device, chnls: dict, rule: MatchRule) -> list:
- """Return a list of field matches."""
- if not any(attr.asdict(rule).values()):
- return [False]
-
- matches = []
- if rule.channel_names:
- channel_names = {ch.name for ch in chnls}
- matches.append(rule.channel_names.issubset(channel_names))
-
- if rule.generic_ids:
- all_generic_ids = {ch.generic_id for ch in chnls}
- matches.append(rule.generic_ids.issubset(all_generic_ids))
-
- if rule.manufacturers:
- if callable(rule.manufacturers):
- matches.append(rule.manufacturers(zha_device.manufacturer))
- else:
- matches.append(zha_device.manufacturer in rule.manufacturers)
-
- if rule.models:
- if callable(rule.models):
- matches.append(rule.models(zha_device.model))
- else:
- matches.append(zha_device.model in rule.models)
-
- return matches
-
ZHA_ENTITIES = ZHAEntityRegistry()
diff --git a/homeassistant/components/zha/core/typing.py b/homeassistant/components/zha/core/typing.py
new file mode 100644
index 00000000000..fb397ea15ae
--- /dev/null
+++ b/homeassistant/components/zha/core/typing.py
@@ -0,0 +1,40 @@
+"""Typing helpers for ZHA component."""
+
+from typing import TYPE_CHECKING, Callable, TypeVar
+
+import zigpy.device
+import zigpy.endpoint
+import zigpy.zcl
+import zigpy.zdo
+
+# pylint: disable=invalid-name
+CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable)
+ChannelType = "ZigbeeChannel"
+ChannelsType = "Channels"
+ChannelPoolType = "ChannelPool"
+EventRelayChannelType = "EventRelayChannel"
+ZDOChannelType = "ZDOChannel"
+ZhaDeviceType = "ZHADevice"
+ZhaEntityType = "ZHAEntity"
+ZhaGatewayType = "ZHAGateway"
+ZigpyClusterType = zigpy.zcl.Cluster
+ZigpyDeviceType = zigpy.device.Device
+ZigpyEndpointType = zigpy.endpoint.Endpoint
+ZigpyZdoType = zigpy.zdo.ZDO
+
+if TYPE_CHECKING:
+ import homeassistant.components.zha.core.channels as channels
+ import homeassistant.components.zha.core.channels.base as base_channels
+ import homeassistant.components.zha.core.device
+ import homeassistant.components.zha.core.gateway
+ import homeassistant.components.zha.entity
+ import homeassistant.components.zha.core.channels
+
+ ChannelType = base_channels.ZigbeeChannel
+ ChannelsType = channels.Channels
+ ChannelPoolType = channels.ChannelPool
+ EventRelayChannelType = base_channels.EventRelayChannel
+ ZDOChannelType = base_channels.ZDOChannel
+ ZhaDeviceType = homeassistant.components.zha.core.device.ZHADevice
+ ZhaEntityType = homeassistant.components.zha.entity.ZhaEntity
+ ZhaGatewayType = homeassistant.components.zha.core.gateway.ZHAGateway
diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py
index 3eeb73a23fd..46f7dd0e031 100644
--- a/homeassistant/components/zha/cover.py
+++ b/homeassistant/components/zha/cover.py
@@ -10,12 +10,13 @@ from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_O
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from .core import discovery
from .core.const import (
CHANNEL_COVER,
DATA_ZHA,
DATA_ZHA_DISPATCHERS,
+ SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
- ZHA_DISCOVERY_NEW,
)
from .core.registries import ZHA_ENTITIES
from .entity import ZhaEntity
@@ -28,41 +29,17 @@ STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Zigbee Home Automation cover from config entry."""
-
- async def async_discover(discovery_info):
- await _async_setup_entities(
- hass, config_entry, async_add_entities, [discovery_info]
- )
+ entities_to_create = hass.data[DATA_ZHA][DOMAIN] = []
unsub = async_dispatcher_connect(
- hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover
+ hass,
+ SIGNAL_ADD_ENTITIES,
+ functools.partial(
+ discovery.async_add_entities, async_add_entities, entities_to_create
+ ),
)
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
- covers = hass.data.get(DATA_ZHA, {}).get(DOMAIN)
- if covers is not None:
- await _async_setup_entities(
- hass, config_entry, async_add_entities, covers.values()
- )
- del hass.data[DATA_ZHA][DOMAIN]
-
-
-async def _async_setup_entities(
- hass, config_entry, async_add_entities, discovery_infos
-):
- """Set up the ZHA covers."""
- entities = []
- for discovery_info in discovery_infos:
- zha_dev = discovery_info["zha_device"]
- channels = discovery_info["channels"]
-
- entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, ZhaCover)
- if entity:
- entities.append(entity(**discovery_info))
-
- if entities:
- async_add_entities(entities, update_before_add=True)
-
@STRICT_MATCH(channel_names=CHANNEL_COVER)
class ZhaCover(ZhaEntity, CoverDevice):
@@ -114,41 +91,41 @@ class ZhaCover(ZhaEntity, CoverDevice):
return self._current_position
@callback
- def async_set_position(self, pos):
+ def async_set_position(self, attr_id, attr_name, value):
"""Handle position update from channel."""
- _LOGGER.debug("setting position: %s", pos)
- self._current_position = 100 - pos
+ _LOGGER.debug("setting position: %s", value)
+ self._current_position = 100 - value
if self._current_position == 0:
self._state = STATE_CLOSED
elif self._current_position == 100:
self._state = STATE_OPEN
- self.async_schedule_update_ha_state()
+ self.async_write_ha_state()
@callback
- def async_set_state(self, state):
+ def async_update_state(self, state):
"""Handle state update from channel."""
_LOGGER.debug("state=%s", state)
self._state = state
- self.async_schedule_update_ha_state()
+ self.async_write_ha_state()
async def async_open_cover(self, **kwargs):
"""Open the window cover."""
res = await self._cover_channel.up_open()
if isinstance(res, list) and res[1] is Status.SUCCESS:
- self.async_set_state(STATE_OPENING)
+ self.async_update_state(STATE_OPENING)
async def async_close_cover(self, **kwargs):
"""Close the window cover."""
res = await self._cover_channel.down_close()
if isinstance(res, list) and res[1] is Status.SUCCESS:
- self.async_set_state(STATE_CLOSING)
+ self.async_update_state(STATE_CLOSING)
async def async_set_cover_position(self, **kwargs):
"""Move the roller shutter to a specific position."""
new_pos = kwargs[ATTR_POSITION]
res = await self._cover_channel.go_to_lift_percentage(100 - new_pos)
if isinstance(res, list) and res[1] is Status.SUCCESS:
- self.async_set_state(
+ self.async_update_state(
STATE_CLOSING if new_pos < self._current_position else STATE_OPENING
)
@@ -157,7 +134,7 @@ class ZhaCover(ZhaEntity, CoverDevice):
res = await self._cover_channel.stop()
if isinstance(res, list) and res[1] is Status.SUCCESS:
self._state = STATE_OPEN if self._current_position > 0 else STATE_CLOSED
- self.async_schedule_update_ha_state()
+ self.async_write_ha_state()
async def async_update(self):
"""Attempt to retrieve the open/close state of the cover."""
diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py
index 60cfa0eec00..46363939190 100644
--- a/homeassistant/components/zha/device_action.py
+++ b/homeassistant/components/zha/device_action.py
@@ -56,12 +56,20 @@ async def async_call_action_from_config(
async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]:
"""List device actions."""
- zha_device = await async_get_zha_device(hass, device_id)
+ try:
+ zha_device = await async_get_zha_device(hass, device_id)
+ except (KeyError, AttributeError):
+ return []
+ cluster_channels = [
+ ch.name
+ for pool in zha_device.channels.pools
+ for ch in pool.claimed_channels.values()
+ ]
actions = [
action
for channel in DEVICE_ACTIONS
for action in DEVICE_ACTIONS[channel]
- if channel in zha_device.cluster_channels
+ if channel in cluster_channels
]
for action in actions:
action[CONF_DEVICE_ID] = device_id
@@ -76,7 +84,10 @@ async def _execute_service_based_action(
) -> None:
action_type = config[CONF_TYPE]
service_name = SERVICE_NAMES[action_type]
- zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID])
+ try:
+ zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID])
+ except (KeyError, AttributeError):
+ return
service_data = {ATTR_IEEE: str(zha_device.ieee)}
diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py
index 76548935814..5fe1dbc0060 100644
--- a/homeassistant/components/zha/device_tracker.py
+++ b/homeassistant/components/zha/device_tracker.py
@@ -8,12 +8,13 @@ from homeassistant.components.device_tracker.config_entry import ScannerEntity
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from .core import discovery
from .core.const import (
CHANNEL_POWER_CONFIGURATION,
DATA_ZHA,
DATA_ZHA_DISPATCHERS,
+ SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
- ZHA_DISCOVERY_NEW,
)
from .core.registries import ZHA_ENTITIES
from .entity import ZhaEntity
@@ -25,51 +26,25 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Zigbee Home Automation device tracker from config entry."""
-
- async def async_discover(discovery_info):
- await _async_setup_entities(
- hass, config_entry, async_add_entities, [discovery_info]
- )
+ entities_to_create = hass.data[DATA_ZHA][DOMAIN] = []
unsub = async_dispatcher_connect(
- hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover
+ hass,
+ SIGNAL_ADD_ENTITIES,
+ functools.partial(
+ discovery.async_add_entities, async_add_entities, entities_to_create
+ ),
)
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
- device_trackers = hass.data.get(DATA_ZHA, {}).get(DOMAIN)
- if device_trackers is not None:
- await _async_setup_entities(
- hass, config_entry, async_add_entities, device_trackers.values()
- )
- del hass.data[DATA_ZHA][DOMAIN]
-
-
-async def _async_setup_entities(
- hass, config_entry, async_add_entities, discovery_infos
-):
- """Set up the ZHA device trackers."""
- entities = []
- for discovery_info in discovery_infos:
- zha_dev = discovery_info["zha_device"]
- channels = discovery_info["channels"]
-
- entity = ZHA_ENTITIES.get_entity(
- DOMAIN, zha_dev, channels, ZHADeviceScannerEntity
- )
- if entity:
- entities.append(entity(**discovery_info))
-
- if entities:
- async_add_entities(entities, update_before_add=True)
-
@STRICT_MATCH(channel_names=CHANNEL_POWER_CONFIGURATION)
class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity):
"""Represent a tracked device."""
- def __init__(self, **kwargs):
+ def __init__(self, unique_id, zha_device, channels, **kwargs):
"""Initialize the ZHA device tracker."""
- super().__init__(**kwargs)
+ super().__init__(unique_id, zha_device, channels, **kwargs)
self._battery_channel = self.cluster_channels.get(CHANNEL_POWER_CONFIGURATION)
self._connected = False
self._keepalive_interval = 60
@@ -108,12 +83,14 @@ class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity):
return SOURCE_TYPE_ROUTER
@callback
- def async_battery_percentage_remaining_updated(self, value):
+ def async_battery_percentage_remaining_updated(self, attr_id, attr_name, value):
"""Handle tracking."""
+ if not attr_name == "battery_percentage_remaining":
+ return
self.debug("battery_percentage_remaining updated: %s", value)
self._connected = True
self._battery_level = Battery.formatter(value)
- self.async_schedule_update_ha_state()
+ self.async_write_ha_state()
@property
def battery_level(self):
diff --git a/homeassistant/components/zha/device_trigger.py b/homeassistant/components/zha/device_trigger.py
index b7c46e5a40a..5f842d7f380 100644
--- a/homeassistant/components/zha/device_trigger.py
+++ b/homeassistant/components/zha/device_trigger.py
@@ -27,7 +27,10 @@ async def async_validate_trigger_config(hass, config):
if "zha" in hass.config.components:
trigger = (config[CONF_TYPE], config[CONF_SUBTYPE])
- zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID])
+ try:
+ zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID])
+ except (KeyError, AttributeError):
+ raise InvalidDeviceAutomationConfig
if (
zha_device.device_automation_triggers is None
or trigger not in zha_device.device_automation_triggers
@@ -40,7 +43,13 @@ async def async_validate_trigger_config(hass, config):
async def async_attach_trigger(hass, config, action, automation_info):
"""Listen for state changes based on configuration."""
trigger = (config[CONF_TYPE], config[CONF_SUBTYPE])
- zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID])
+ try:
+ zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID])
+ except (KeyError, AttributeError):
+ return None
+
+ if trigger not in zha_device.device_automation_triggers:
+ return None
trigger = zha_device.device_automation_triggers[trigger]
diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py
index 6a9dfc63432..4dd3fea016d 100644
--- a/homeassistant/components/zha/entity.py
+++ b/homeassistant/components/zha/entity.py
@@ -44,7 +44,6 @@ class ZhaEntity(RestoreEntity, LogMixin, entity.Entity):
self._zha_device = zha_device
self.cluster_channels = {}
self._available = False
- self._component = kwargs["component"]
self._unsubs = []
self.remove_future = None
for channel in channels:
@@ -103,16 +102,16 @@ class ZhaEntity(RestoreEntity, LogMixin, entity.Entity):
def async_set_available(self, available):
"""Set entity availability."""
self._available = available
- self.async_schedule_update_ha_state()
+ self.async_write_ha_state()
@callback
def async_update_state_attribute(self, key, value):
"""Update a single device state attribute."""
self._device_state_attributes.update({key: value})
- self.async_schedule_update_ha_state()
+ self.async_write_ha_state()
@callback
- def async_set_state(self, state):
+ def async_set_state(self, attr_id, attr_name, value):
"""Set the entity state."""
pass
diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py
index 6ad13d1c802..234566267f6 100644
--- a/homeassistant/components/zha/fan.py
+++ b/homeassistant/components/zha/fan.py
@@ -14,12 +14,13 @@ from homeassistant.components.fan import (
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from .core import discovery
from .core.const import (
CHANNEL_FAN,
DATA_ZHA,
DATA_ZHA_DISPATCHERS,
+ SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
- ZHA_DISCOVERY_NEW,
)
from .core.registries import ZHA_ENTITIES
from .entity import ZhaEntity
@@ -52,41 +53,17 @@ STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Zigbee Home Automation fan from config entry."""
-
- async def async_discover(discovery_info):
- await _async_setup_entities(
- hass, config_entry, async_add_entities, [discovery_info]
- )
+ entities_to_create = hass.data[DATA_ZHA][DOMAIN] = []
unsub = async_dispatcher_connect(
- hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover
+ hass,
+ SIGNAL_ADD_ENTITIES,
+ functools.partial(
+ discovery.async_add_entities, async_add_entities, entities_to_create
+ ),
)
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
- fans = hass.data.get(DATA_ZHA, {}).get(DOMAIN)
- if fans is not None:
- await _async_setup_entities(
- hass, config_entry, async_add_entities, fans.values()
- )
- del hass.data[DATA_ZHA][DOMAIN]
-
-
-async def _async_setup_entities(
- hass, config_entry, async_add_entities, discovery_infos
-):
- """Set up the ZHA fans."""
- entities = []
- for discovery_info in discovery_infos:
- zha_dev = discovery_info["zha_device"]
- channels = discovery_info["channels"]
-
- entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, ZhaFan)
- if entity:
- entities.append(entity(**discovery_info))
-
- if entities:
- async_add_entities(entities, update_before_add=True)
-
@STRICT_MATCH(channel_names=CHANNEL_FAN)
class ZhaFan(ZhaEntity, FanEntity):
@@ -137,10 +114,10 @@ class ZhaFan(ZhaEntity, FanEntity):
return self.state_attributes
@callback
- def async_set_state(self, state):
+ def async_set_state(self, attr_id, attr_name, value):
"""Handle state update from channel."""
- self._state = VALUE_TO_SPEED.get(state, self._state)
- self.async_schedule_update_ha_state()
+ self._state = VALUE_TO_SPEED.get(value, self._state)
+ self.async_write_ha_state()
async def async_turn_on(self, speed: str = None, **kwargs) -> None:
"""Turn the entity on."""
@@ -156,7 +133,7 @@ class ZhaFan(ZhaEntity, FanEntity):
async def async_set_speed(self, speed: str) -> None:
"""Set the speed of the fan."""
await self._fan_channel.async_set_speed(SPEED_TO_VALUE[speed])
- self.async_set_state(speed)
+ self.async_set_state(0, "fan_mode", speed)
async def async_update(self):
"""Attempt to retrieve on off state from the fan."""
diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py
index 409cd339122..435f8940032 100644
--- a/homeassistant/components/zha/light.py
+++ b/homeassistant/components/zha/light.py
@@ -2,6 +2,7 @@
from datetime import timedelta
import functools
import logging
+import random
from zigpy.zcl.foundation import Status
@@ -12,17 +13,22 @@ 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.const import (
CHANNEL_COLOR,
CHANNEL_LEVEL,
CHANNEL_ON_OFF,
DATA_ZHA,
DATA_ZHA_DISPATCHERS,
+ EFFECT_BLINK,
+ EFFECT_BREATHE,
+ EFFECT_DEFAULT_VARIANT,
+ SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
SIGNAL_SET_LEVEL,
- ZHA_DISCOVERY_NEW,
)
from .core.registries import ZHA_ENTITIES
+from .core.typing import ZhaDeviceType
from .entity import ZhaEntity
_LOGGER = logging.getLogger(__name__)
@@ -36,55 +42,33 @@ UPDATE_COLORLOOP_DIRECTION = 0x2
UPDATE_COLORLOOP_TIME = 0x4
UPDATE_COLORLOOP_HUE = 0x8
+FLASH_EFFECTS = {light.FLASH_SHORT: EFFECT_BLINK, light.FLASH_LONG: EFFECT_BREATHE}
+
UNSUPPORTED_ATTRIBUTE = 0x86
-SCAN_INTERVAL = timedelta(minutes=60)
STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, light.DOMAIN)
-PARALLEL_UPDATES = 5
+PARALLEL_UPDATES = 0
+_REFRESH_INTERVAL = (45, 75)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Zigbee Home Automation light from config entry."""
-
- async def async_discover(discovery_info):
- await _async_setup_entities(
- hass, config_entry, async_add_entities, [discovery_info]
- )
+ entities_to_create = hass.data[DATA_ZHA][light.DOMAIN] = []
unsub = async_dispatcher_connect(
- hass, ZHA_DISCOVERY_NEW.format(light.DOMAIN), async_discover
+ hass,
+ SIGNAL_ADD_ENTITIES,
+ functools.partial(
+ discovery.async_add_entities, async_add_entities, entities_to_create
+ ),
)
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
- lights = hass.data.get(DATA_ZHA, {}).get(light.DOMAIN)
- if lights is not None:
- await _async_setup_entities(
- hass, config_entry, async_add_entities, lights.values()
- )
- del hass.data[DATA_ZHA][light.DOMAIN]
-
-async def _async_setup_entities(
- hass, config_entry, async_add_entities, discovery_infos
-):
- """Set up the ZHA lights."""
- entities = []
- for discovery_info in discovery_infos:
- zha_dev = discovery_info["zha_device"]
- channels = discovery_info["channels"]
-
- entity = ZHA_ENTITIES.get_entity(light.DOMAIN, zha_dev, channels, Light)
- if entity:
- entities.append(entity(**discovery_info))
-
- if entities:
- async_add_entities(entities, update_before_add=True)
-
-
-@STRICT_MATCH(channel_names=CHANNEL_ON_OFF)
+@STRICT_MATCH(channel_names=CHANNEL_ON_OFF, aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL})
class Light(ZhaEntity, light.Light):
"""Representation of a ZHA or ZLL light."""
- def __init__(self, unique_id, zha_device, channels, **kwargs):
+ 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
@@ -97,6 +81,8 @@ class Light(ZhaEntity, light.Light):
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
if self._level_channel:
self._supported_features |= light.SUPPORT_BRIGHTNESS
@@ -116,6 +102,9 @@ class Light(ZhaEntity, light.Light):
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 is_on(self) -> bool:
"""Return true if entity is on."""
@@ -143,7 +132,7 @@ class Light(ZhaEntity, light.Light):
"""
value = max(0, min(254, value))
self._brightness = value
- self.async_schedule_update_ha_state()
+ self.async_write_ha_state()
@property
def hs_color(self):
@@ -171,12 +160,12 @@ class Light(ZhaEntity, light.Light):
return self._supported_features
@callback
- def async_set_state(self, state):
+ def async_set_state(self, attr_id, attr_name, value):
"""Set the state."""
- self._state = bool(state)
- if state:
+ self._state = bool(value)
+ if value:
self._off_brightness = None
- self.async_schedule_update_ha_state()
+ self.async_write_ha_state()
async def async_added_to_hass(self):
"""Run when about to be added to hass."""
@@ -188,7 +177,15 @@ class Light(ZhaEntity, light.Light):
await self.async_accept_signal(
self._level_channel, SIGNAL_SET_LEVEL, self.set_level
)
- async_track_time_interval(self.hass, self.refresh, SCAN_INTERVAL)
+ 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):
@@ -211,6 +208,7 @@ class Light(ZhaEntity, light.Light):
duration = transition * 10 if transition else 0
brightness = kwargs.get(light.ATTR_BRIGHTNESS)
effect = kwargs.get(light.ATTR_EFFECT)
+ flash = kwargs.get(light.ATTR_FLASH)
if brightness is None and self._off_brightness is not None:
brightness = self._off_brightness
@@ -300,9 +298,15 @@ class Light(ZhaEntity, light.Light):
t_log["color_loop_set"] = result
self._effect = None
+ if flash is not None and self._supported_features & light.SUPPORT_FLASH:
+ result = await self._identify_channel.trigger_effect(
+ FLASH_EFFECTS[flash], EFFECT_DEFAULT_VARIANT
+ )
+ t_log["trigger_effect"] = result
+
self._off_brightness = None
self.debug("turned on: %s", t_log)
- self.async_schedule_update_ha_state()
+ self.async_write_ha_state()
async def async_turn_off(self, **kwargs):
"""Turn the entity off."""
@@ -324,7 +328,7 @@ class Light(ZhaEntity, light.Light):
# store current brightness so that the next turn_on uses it.
self._off_brightness = self._brightness
- self.async_schedule_update_ha_state()
+ self.async_write_ha_state()
async def async_update(self):
"""Attempt to retrieve on off state from the light."""
@@ -335,46 +339,62 @@ class Light(ZhaEntity, light.Light):
"""Attempt to retrieve on off state from the light."""
self.debug("polling current state")
if self._on_off_channel:
- self._state = await self._on_off_channel.get_attribute_value(
+ state = await self._on_off_channel.get_attribute_value(
"on_off", from_cache=from_cache
)
+ if state is not None:
+ self._state = state
if self._level_channel:
- self._brightness = await self._level_channel.get_attribute_value(
+ level = await self._level_channel.get_attribute_value(
"current_level", from_cache=from_cache
)
+ if level is not None:
+ self._brightness = level
if self._color_channel:
+ attributes = []
color_capabilities = self._color_channel.get_color_capabilities()
if (
color_capabilities is not None
and color_capabilities & CAPABILITIES_COLOR_TEMP
):
- self._color_temp = await self._color_channel.get_attribute_value(
- "color_temperature", from_cache=from_cache
- )
+ attributes.append("color_temperature")
if (
color_capabilities is not None
and color_capabilities & CAPABILITIES_COLOR_XY
):
- color_x = await self._color_channel.get_attribute_value(
- "current_x", from_cache=from_cache
- )
- color_y = await self._color_channel.get_attribute_value(
- "current_y", from_cache=from_cache
- )
- if color_x is not None and color_y is not None:
- self._hs_color = color_util.color_xy_to_hs(
- float(color_x / 65535), float(color_y / 65535)
- )
+ attributes.append("current_x")
+ attributes.append("current_y")
if (
color_capabilities is not None
and color_capabilities & CAPABILITIES_COLOR_LOOP
):
- color_loop_active = await self._color_channel.get_attribute_value(
- "color_loop_active", from_cache=from_cache
+ attributes.append("color_loop_active")
+
+ results = await self._color_channel.get_attributes(
+ attributes, from_cache=from_cache
+ )
+
+ if (
+ "color_temperature" in results
+ and results["color_temperature"] is not None
+ ):
+ self._color_temp = results["color_temperature"]
+
+ color_x = results.get("color_x")
+ color_y = results.get("color_y")
+ if color_x is not None and color_y is not None:
+ self._hs_color = color_util.color_xy_to_hs(
+ float(color_x / 65535), float(color_y / 65535)
)
- if color_loop_active is not None and color_loop_active == 1:
+ if (
+ "color_loop_active" in results
+ and results["color_loop_active"] is not None
+ ):
+ color_loop_active = results["color_loop_active"]
+ if color_loop_active == 1:
self._effect = light.EFFECT_COLORLOOP
- async def refresh(self, time):
+ async def _refresh(self, time):
"""Call async_get_state at an interval."""
await self.async_get_state(from_cache=False)
+ self.async_write_ha_state()
diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py
index b173c166a77..5c0d54430e0 100644
--- a/homeassistant/components/zha/lock.py
+++ b/homeassistant/components/zha/lock.py
@@ -13,12 +13,13 @@ from homeassistant.components.lock import (
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from .core import discovery
from .core.const import (
CHANNEL_DOORLOCK,
DATA_ZHA,
DATA_ZHA_DISPATCHERS,
+ SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
- ZHA_DISCOVERY_NEW,
)
from .core.registries import ZHA_ENTITIES
from .entity import ZhaEntity
@@ -35,41 +36,17 @@ VALUE_TO_STATE = dict(enumerate(STATE_LIST))
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Zigbee Home Automation Door Lock from config entry."""
-
- async def async_discover(discovery_info):
- await _async_setup_entities(
- hass, config_entry, async_add_entities, [discovery_info]
- )
+ entities_to_create = hass.data[DATA_ZHA][DOMAIN] = []
unsub = async_dispatcher_connect(
- hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover
+ hass,
+ SIGNAL_ADD_ENTITIES,
+ functools.partial(
+ discovery.async_add_entities, async_add_entities, entities_to_create
+ ),
)
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
- locks = hass.data.get(DATA_ZHA, {}).get(DOMAIN)
- if locks is not None:
- await _async_setup_entities(
- hass, config_entry, async_add_entities, locks.values()
- )
- del hass.data[DATA_ZHA][DOMAIN]
-
-
-async def _async_setup_entities(
- hass, config_entry, async_add_entities, discovery_infos
-):
- """Set up the ZHA locks."""
- entities = []
- for discovery_info in discovery_infos:
- zha_dev = discovery_info["zha_device"]
- channels = discovery_info["channels"]
-
- entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, ZhaDoorLock)
- if entity:
- entities.append(entity(**discovery_info))
-
- if entities:
- async_add_entities(entities, update_before_add=True)
-
@STRICT_MATCH(channel_names=CHANNEL_DOORLOCK)
class ZhaDoorLock(ZhaEntity, LockDevice):
@@ -110,7 +87,7 @@ class ZhaDoorLock(ZhaEntity, LockDevice):
if not isinstance(result, list) or result[0] is not Status.SUCCESS:
self.error("Error with lock_door: %s", result)
return
- self.async_schedule_update_ha_state()
+ self.async_write_ha_state()
async def async_unlock(self, **kwargs):
"""Unlock the lock."""
@@ -118,7 +95,7 @@ class ZhaDoorLock(ZhaEntity, LockDevice):
if not isinstance(result, list) or result[0] is not Status.SUCCESS:
self.error("Error with unlock_door: %s", result)
return
- self.async_schedule_update_ha_state()
+ self.async_write_ha_state()
async def async_update(self):
"""Attempt to retrieve state from the lock."""
@@ -126,10 +103,10 @@ class ZhaDoorLock(ZhaEntity, LockDevice):
await self.async_get_state()
@callback
- def async_set_state(self, state):
+ def async_set_state(self, attr_id, attr_name, value):
"""Handle state update from channel."""
- self._state = VALUE_TO_STATE.get(state, self._state)
- self.async_schedule_update_ha_state()
+ self._state = VALUE_TO_STATE.get(value, self._state)
+ self.async_write_ha_state()
async def async_get_state(self, from_cache=True):
"""Attempt to retrieve state from the lock."""
diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json
index 16c5604587d..19940eaea00 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.13.2",
- "zha-quirks==0.0.33",
+ "bellows-homeassistant==0.14.0",
+ "zha-quirks==0.0.37",
"zigpy-cc==0.1.0",
"zigpy-deconz==0.7.0",
- "zigpy-homeassistant==0.13.2",
- "zigpy-xbee-homeassistant==0.9.0",
+ "zigpy-homeassistant==0.16.0",
+ "zigpy-xbee-homeassistant==0.10.0",
"zigpy-zigate==0.5.1"
],
"dependencies": [],
diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py
index 8b7dd894973..8182fdcabcf 100644
--- a/homeassistant/components/zha/sensor.py
+++ b/homeassistant/components/zha/sensor.py
@@ -17,12 +17,15 @@ from homeassistant.const import (
POWER_WATT,
STATE_UNKNOWN,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util.temperature import fahrenheit_to_celsius
+from .core import discovery
from .core.const import (
+ CHANNEL_ANALOG_INPUT,
CHANNEL_ELECTRICAL_MEASUREMENT,
CHANNEL_HUMIDITY,
CHANNEL_ILLUMINANCE,
@@ -33,9 +36,9 @@ from .core.const import (
CHANNEL_TEMPERATURE,
DATA_ZHA,
DATA_ZHA_DISPATCHERS,
+ SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
SIGNAL_STATE_ATTR,
- ZHA_DISCOVERY_NEW,
)
from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES
from .entity import ZhaEntity
@@ -65,50 +68,22 @@ STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Zigbee Home Automation sensor from config entry."""
-
- async def async_discover(discovery_info):
- await _async_setup_entities(
- hass, config_entry, async_add_entities, [discovery_info]
- )
+ entities_to_create = hass.data[DATA_ZHA][DOMAIN] = []
unsub = async_dispatcher_connect(
- hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover
+ hass,
+ SIGNAL_ADD_ENTITIES,
+ functools.partial(
+ discovery.async_add_entities, async_add_entities, entities_to_create
+ ),
)
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
- sensors = hass.data.get(DATA_ZHA, {}).get(DOMAIN)
- if sensors is not None:
- await _async_setup_entities(
- hass, config_entry, async_add_entities, sensors.values()
- )
- del hass.data[DATA_ZHA][DOMAIN]
-
-
-async def _async_setup_entities(
- hass, config_entry, async_add_entities, discovery_infos
-):
- """Set up the ZHA sensors."""
- entities = []
- for discovery_info in discovery_infos:
- entities.append(await make_sensor(discovery_info))
-
- if entities:
- async_add_entities(entities, update_before_add=True)
-
-
-async def make_sensor(discovery_info):
- """Create ZHA sensors factory."""
-
- zha_dev = discovery_info["zha_device"]
- channels = discovery_info["channels"]
-
- entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, Sensor)
- return entity(**discovery_info)
-
class Sensor(ZhaEntity):
"""Base ZHA sensor."""
+ SENSOR_ATTR = None
_decimals = 1
_device_class = None
_divisor = 1
@@ -150,12 +125,14 @@ class Sensor(ZhaEntity):
return self._state
@callback
- def async_set_state(self, state):
+ def async_set_state(self, attr_id, attr_name, value):
"""Handle state update from channel."""
- if state is not None:
- state = self.formatter(state)
- self._state = state
- self.async_schedule_update_ha_state()
+ if self.SENSOR_ATTR is None or self.SENSOR_ATTR != attr_name:
+ return
+ if value is not None:
+ value = self.formatter(value)
+ self._state = value
+ self.async_write_ha_state()
@callback
def async_restore_last_state(self, last_state):
@@ -176,12 +153,21 @@ class Sensor(ZhaEntity):
return round(float(value * self._multiplier) / self._divisor)
+@STRICT_MATCH(channel_names=CHANNEL_ANALOG_INPUT)
+class AnalogInput(Sensor):
+ """Sensor that displays analog input values."""
+
+ SENSOR_ATTR = "present_value"
+ pass
+
+
@STRICT_MATCH(channel_names=CHANNEL_POWER_CONFIGURATION)
class Battery(Sensor):
"""Battery sensor of power configuration cluster."""
+ SENSOR_ATTR = "battery_percentage_remaining"
_device_class = DEVICE_CLASS_BATTERY
- _unit = "%"
+ _unit = UNIT_PERCENTAGE
@staticmethod
def formatter(value):
@@ -195,10 +181,12 @@ class Battery(Sensor):
async def async_state_attr_provider(self):
"""Return device state attrs for battery sensors."""
state_attrs = {}
- battery_size = await self._channel.get_attribute_value("battery_size")
+ attributes = ["battery_size", "battery_quantity"]
+ results = await self._channel.get_attributes(attributes)
+ battery_size = results.get("battery_size")
if battery_size is not None:
state_attrs["battery_size"] = BATTERY_SIZES.get(battery_size, "Unknown")
- battery_quantity = await self._channel.get_attribute_value("battery_quantity")
+ battery_quantity = results.get("battery_quantity")
if battery_quantity is not None:
state_attrs["battery_quantity"] = battery_quantity
return state_attrs
@@ -208,13 +196,14 @@ class Battery(Sensor):
"""Update a single device state attribute."""
if key == "battery_voltage":
self._device_state_attributes[key] = round(value / 10, 1)
- self.async_schedule_update_ha_state()
+ self.async_write_ha_state()
@STRICT_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT)
class ElectricalMeasurement(Sensor):
"""Active power measurement."""
+ SENSOR_ATTR = "active_power"
_device_class = DEVICE_CLASS_POWER
_divisor = 10
_unit = POWER_WATT
@@ -249,15 +238,17 @@ class Text(Sensor):
class Humidity(Sensor):
"""Humidity sensor."""
+ SENSOR_ATTR = "measured_value"
_device_class = DEVICE_CLASS_HUMIDITY
_divisor = 100
- _unit = "%"
+ _unit = UNIT_PERCENTAGE
@STRICT_MATCH(channel_names=CHANNEL_ILLUMINANCE)
class Illuminance(Sensor):
"""Illuminance Sensor."""
+ SENSOR_ATTR = "measured_value"
_device_class = DEVICE_CLASS_ILLUMINANCE
_unit = "lx"
@@ -271,6 +262,7 @@ class Illuminance(Sensor):
class SmartEnergyMetering(Sensor):
"""Metering sensor."""
+ SENSOR_ATTR = "instantaneous_demand"
_device_class = DEVICE_CLASS_POWER
def formatter(self, value):
@@ -287,6 +279,7 @@ class SmartEnergyMetering(Sensor):
class Pressure(Sensor):
"""Pressure sensor."""
+ SENSOR_ATTR = "measured_value"
_device_class = DEVICE_CLASS_PRESSURE
_decimals = 0
_unit = "hPa"
@@ -296,6 +289,7 @@ class Pressure(Sensor):
class Temperature(Sensor):
"""Temperature Sensor."""
+ SENSOR_ATTR = "measured_value"
_device_class = DEVICE_CLASS_TEMPERATURE
_divisor = 100
_unit = TEMP_CELSIUS
diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json
index a41f6de24be..a015ca30770 100644
--- a/homeassistant/components/zha/strings.json
+++ b/homeassistant/components/zha/strings.json
@@ -31,6 +31,14 @@
"remote_button_triple_press": "\"{subtype}\" button triple clicked",
"remote_button_quadruple_press": "\"{subtype}\" button quadruple clicked",
"remote_button_quintuple_press": "\"{subtype}\" button quintuple clicked",
+ "remote_button_alt_short_press": "\"{subtype}\" button pressed (Alternate mode)",
+ "remote_button_alt_short_release": "\"{subtype}\" button released (Alternate mode)",
+ "remote_button_alt_long_press": "\"{subtype}\" button continuously pressed (Alternate mode)",
+ "remote_button_alt_long_release": "\"{subtype}\" button released after long press (Alternate mode)",
+ "remote_button_alt_double_press": "\"{subtype}\" button double clicked (Alternate mode)",
+ "remote_button_alt_triple_press": "\"{subtype}\" button triple clicked (Alternate mode)",
+ "remote_button_alt_quadruple_press": "\"{subtype}\" button quadruple clicked (Alternate mode)",
+ "remote_button_alt_quintuple_press": "\"{subtype}\" button quintuple clicked (Alternate mode)",
"device_rotated": "Device rotated \"{subtype}\"",
"device_shaken": "Device shaken",
"device_slid": "Device slid \"{subtype}\"",
diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py
index 1280ace34dc..156183ce95d 100644
--- a/homeassistant/components/zha/switch.py
+++ b/homeassistant/components/zha/switch.py
@@ -9,12 +9,13 @@ from homeassistant.const import STATE_ON
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from .core import discovery
from .core.const import (
CHANNEL_ON_OFF,
DATA_ZHA,
DATA_ZHA_DISPATCHERS,
+ SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
- ZHA_DISCOVERY_NEW,
)
from .core.registries import ZHA_ENTITIES
from .entity import ZhaEntity
@@ -25,49 +26,25 @@ STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Zigbee Home Automation switch from config entry."""
-
- async def async_discover(discovery_info):
- await _async_setup_entities(
- hass, config_entry, async_add_entities, [discovery_info]
- )
+ entities_to_create = hass.data[DATA_ZHA][DOMAIN] = []
unsub = async_dispatcher_connect(
- hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover
+ hass,
+ SIGNAL_ADD_ENTITIES,
+ functools.partial(
+ discovery.async_add_entities, async_add_entities, entities_to_create
+ ),
)
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
- switches = hass.data.get(DATA_ZHA, {}).get(DOMAIN)
- if switches is not None:
- await _async_setup_entities(
- hass, config_entry, async_add_entities, switches.values()
- )
- del hass.data[DATA_ZHA][DOMAIN]
-
-
-async def _async_setup_entities(
- hass, config_entry, async_add_entities, discovery_infos
-):
- """Set up the ZHA switches."""
- entities = []
- for discovery_info in discovery_infos:
- zha_dev = discovery_info["zha_device"]
- channels = discovery_info["channels"]
-
- entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, Switch)
- if entity:
- entities.append(entity(**discovery_info))
-
- if entities:
- async_add_entities(entities, update_before_add=True)
-
@STRICT_MATCH(channel_names=CHANNEL_ON_OFF)
class Switch(ZhaEntity, SwitchDevice):
"""ZHA switch."""
- def __init__(self, **kwargs):
+ def __init__(self, unique_id, zha_device, channels, **kwargs):
"""Initialize the ZHA switch."""
- super().__init__(**kwargs)
+ super().__init__(unique_id, zha_device, channels, **kwargs)
self._on_off_channel = self.cluster_channels.get(CHANNEL_ON_OFF)
@property
@@ -83,7 +60,7 @@ class Switch(ZhaEntity, SwitchDevice):
if not isinstance(result, list) or result[1] is not Status.SUCCESS:
return
self._state = True
- self.async_schedule_update_ha_state()
+ self.async_write_ha_state()
async def async_turn_off(self, **kwargs):
"""Turn the entity off."""
@@ -91,13 +68,13 @@ class Switch(ZhaEntity, SwitchDevice):
if not isinstance(result, list) or result[1] is not Status.SUCCESS:
return
self._state = False
- self.async_schedule_update_ha_state()
+ self.async_write_ha_state()
@callback
- def async_set_state(self, state):
+ def async_set_state(self, attr_id, attr_name, value):
"""Handle state update from channel."""
- self._state = bool(state)
- self.async_schedule_update_ha_state()
+ self._state = bool(value)
+ self.async_write_ha_state()
@property
def device_state_attributes(self):
@@ -120,4 +97,6 @@ class Switch(ZhaEntity, SwitchDevice):
"""Attempt to retrieve on off state from the switch."""
await super().async_update()
if self._on_off_channel:
- self._state = await self._on_off_channel.get_attribute_value("on_off")
+ state = await self._on_off_channel.get_attribute_value("on_off")
+ if state is not None:
+ self._state = state
diff --git a/homeassistant/components/zigbee/__init__.py b/homeassistant/components/zigbee/__init__.py
index 3a4fa9029bd..48c96b7591f 100644
--- a/homeassistant/components/zigbee/__init__.py
+++ b/homeassistant/components/zigbee/__init__.py
@@ -15,6 +15,7 @@ from homeassistant.const import (
CONF_NAME,
CONF_PIN,
EVENT_HOMEASSISTANT_STOP,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
@@ -448,7 +449,7 @@ class ZigBeeAnalogIn(Entity):
@property
def unit_of_measurement(self):
"""Return the unit this state is expressed in."""
- return "%"
+ return UNIT_PERCENTAGE
def update(self):
"""Get the latest reading from the ADC."""
diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py
index 33ac15853f4..c71026ea79c 100644
--- a/homeassistant/components/zone/__init__.py
+++ b/homeassistant/components/zone/__init__.py
@@ -1,6 +1,6 @@
"""Support for the definition of zones."""
import logging
-from typing import Dict, List, Optional, cast
+from typing import Dict, Optional, cast
import voluptuous as vol
@@ -159,32 +159,12 @@ class ZoneStorageCollection(collection.StorageCollection):
return {**data, **update_data}
-class IDLessCollection(collection.ObservableCollection):
- """A collection without IDs."""
-
- counter = 0
-
- async def async_load(self, data: List[dict]) -> None:
- """Load the collection. Overrides existing data."""
- for item_id in list(self.data):
- await self.notify_change(collection.CHANGE_REMOVED, item_id, None)
-
- self.data.clear()
-
- for item in data:
- self.counter += 1
- item_id = f"fakeid-{self.counter}"
-
- self.data[item_id] = item
- await self.notify_change(collection.CHANGE_ADDED, item_id, item)
-
-
async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
"""Set up configured zones as well as Home Assistant zone if necessary."""
component = entity_component.EntityComponent(_LOGGER, DOMAIN, hass)
id_manager = collection.IDManager()
- yaml_collection = IDLessCollection(
+ yaml_collection = collection.IDLessCollection(
logging.getLogger(f"{__name__}.yaml_collection"), id_manager
)
collection.attach_entity_component_collection(
@@ -209,9 +189,7 @@ async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS
).async_setup(hass)
- async def _collection_changed(
- change_type: str, item_id: str, config: Optional[Dict]
- ) -> None:
+ async def _collection_changed(change_type: str, item_id: str, config: Dict) -> None:
"""Handle a collection change: clean up entity registry on removals."""
if change_type != collection.CHANGE_REMOVED:
return
diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py
index ba7e26ee58c..1491c10777f 100644
--- a/homeassistant/components/zwave/__init__.py
+++ b/homeassistant/components/zwave/__init__.py
@@ -126,8 +126,8 @@ SET_CONFIG_PARAMETER_SCHEMA = vol.Schema(
SET_NODE_VALUE_SCHEMA = vol.Schema(
{
vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
- vol.Required(const.ATTR_VALUE_ID): vol.Coerce(int),
- vol.Required(const.ATTR_CONFIG_VALUE): vol.Coerce(int),
+ vol.Required(const.ATTR_VALUE_ID): vol.Any(vol.Coerce(int), cv.string),
+ vol.Required(const.ATTR_CONFIG_VALUE): vol.Any(vol.Coerce(int), cv.string),
}
)
diff --git a/homeassistant/components/zwave/manifest.json b/homeassistant/components/zwave/manifest.json
index 1fc6401f25b..81978aa96cd 100644
--- a/homeassistant/components/zwave/manifest.json
+++ b/homeassistant/components/zwave/manifest.json
@@ -3,7 +3,7 @@
"name": "Z-Wave",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/zwave",
- "requirements": ["homeassistant-pyozw==0.1.8", "pydispatcher==2.0.5"],
+ "requirements": ["homeassistant-pyozw==0.1.9", "pydispatcher==2.0.5"],
"dependencies": [],
"codeowners": ["@home-assistant/z-wave"]
}
diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml
index 9d3d2b0cadf..d908941fb92 100644
--- a/homeassistant/components/zwave/services.yaml
+++ b/homeassistant/components/zwave/services.yaml
@@ -75,9 +75,9 @@ set_node_value:
node_id:
description: Node id of the device to set the value on (integer).
value_id:
- description: Value id of the value to set (integer).
+ description: Value id of the value to set (integer or string).
value:
- description: Value to set (integer).
+ description: Value to set (integer or string).
refresh_node_value:
description: Refresh the value for a given value_id on a Z-Wave device.
diff --git a/homeassistant/config.py b/homeassistant/config.py
index 6ff571f0d6b..abb8511cab0 100644
--- a/homeassistant/config.py
+++ b/homeassistant/config.py
@@ -89,7 +89,7 @@ scene: !include {SCENE_CONFIG_PATH}
"""
DEFAULT_SECRETS = """
# Use this file to store secrets like usernames and passwords.
-# Learn more at https://home-assistant.io/docs/configuration/secrets/
+# Learn more at https://www.home-assistant.io/docs/configuration/secrets/
some_password: welcome
"""
TTS_PRE_92 = """
diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py
index 1cec1e75fe9..945bd3865c3 100644
--- a/homeassistant/config_entries.py
+++ b/homeassistant/config_entries.py
@@ -2,6 +2,7 @@
import asyncio
import functools
import logging
+from types import MappingProxyType
from typing import Any, Callable, Dict, List, Optional, Set, Union, cast
import uuid
import weakref
@@ -139,10 +140,10 @@ class ConfigEntry:
self.title = title
# Config data
- self.data = data
+ self.data = MappingProxyType(data)
# Entry options
- self.options = options or {}
+ self.options = MappingProxyType(options or {})
# Entry system options
self.system_options = SystemOptions(**system_options)
@@ -396,8 +397,8 @@ class ConfigEntry:
"version": self.version,
"domain": self.domain,
"title": self.title,
- "data": self.data,
- "options": self.options,
+ "data": dict(self.data),
+ "options": dict(self.options),
"system_options": self.system_options.as_dict(),
"source": self.source,
"connection_class": self.connection_class,
@@ -720,6 +721,7 @@ class ConfigEntries:
entry: ConfigEntry,
*,
unique_id: Union[str, dict, None] = _UNDEF,
+ title: Union[str, dict] = _UNDEF,
data: dict = _UNDEF,
options: dict = _UNDEF,
system_options: dict = _UNDEF,
@@ -728,11 +730,14 @@ class ConfigEntries:
if unique_id is not _UNDEF:
entry.unique_id = cast(Optional[str], unique_id)
+ if title is not _UNDEF:
+ entry.title = cast(str, title)
+
if data is not _UNDEF:
- entry.data = data
+ entry.data = MappingProxyType(data)
if options is not _UNDEF:
- entry.options = options
+ entry.options = MappingProxyType(options)
if system_options is not _UNDEF:
entry.system_options.update(**system_options)
@@ -818,7 +823,9 @@ class ConfigFlow(data_entry_flow.FlowHandler):
raise data_entry_flow.UnknownHandler
@callback
- def _abort_if_unique_id_configured(self, updates: Dict[Any, Any] = None) -> None:
+ def _abort_if_unique_id_configured(
+ self, updates: Optional[Dict[Any, Any]] = None
+ ) -> None:
"""Abort if the unique ID is already configured."""
assert self.hass
if self.unique_id is None:
diff --git a/homeassistant/const.py b/homeassistant/const.py
index 224083ccb74..a8a8b898ebb 100644
--- a/homeassistant/const.py
+++ b/homeassistant/const.py
@@ -1,7 +1,7 @@
"""Constants used by Home Assistant components."""
MAJOR_VERSION = 0
-MINOR_VERSION = 106
-PATCH_VERSION = "6"
+MINOR_VERSION = 107
+PATCH_VERSION = "0"
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__ = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER = (3, 7, 0)
@@ -35,9 +35,9 @@ CONF_ALIAS = "alias"
CONF_API_KEY = "api_key"
CONF_API_VERSION = "api_version"
CONF_AT = "at"
-CONF_AUTHENTICATION = "authentication"
CONF_AUTH_MFA_MODULES = "auth_mfa_modules"
CONF_AUTH_PROVIDERS = "auth_providers"
+CONF_AUTHENTICATION = "authentication"
CONF_BASE = "base"
CONF_BEFORE = "before"
CONF_BELOW = "below"
@@ -57,11 +57,13 @@ CONF_COMMAND_OPEN = "command_open"
CONF_COMMAND_STATE = "command_state"
CONF_COMMAND_STOP = "command_stop"
CONF_CONDITION = "condition"
+CONF_CONTINUE_ON_TIMEOUT = "continue_on_timeout"
CONF_COVERS = "covers"
CONF_CURRENCY = "currency"
CONF_CUSTOMIZE = "customize"
CONF_CUSTOMIZE_DOMAIN = "customize_domain"
CONF_CUSTOMIZE_GLOB = "customize_glob"
+CONF_DELAY = "delay"
CONF_DELAY_TIME = "delay_time"
CONF_DEVICE = "device"
CONF_DEVICE_CLASS = "device_class"
@@ -82,6 +84,8 @@ CONF_ENTITY_ID = "entity_id"
CONF_ENTITY_NAMESPACE = "entity_namespace"
CONF_ENTITY_PICTURE_TEMPLATE = "entity_picture_template"
CONF_EVENT = "event"
+CONF_EVENT_DATA = "event_data"
+CONF_EVENT_DATA_TEMPLATE = "event_data_template"
CONF_EXCLUDE = "exclude"
CONF_FILE_PATH = "file_path"
CONF_FILENAME = "filename"
@@ -95,15 +99,15 @@ CONF_HOSTS = "hosts"
CONF_HS = "hs"
CONF_ICON = "icon"
CONF_ICON_TEMPLATE = "icon_template"
-CONF_INCLUDE = "include"
CONF_ID = "id"
+CONF_INCLUDE = "include"
CONF_IP_ADDRESS = "ip_address"
CONF_LATITUDE = "latitude"
-CONF_LONGITUDE = "longitude"
CONF_LIGHTS = "lights"
+CONF_LONGITUDE = "longitude"
CONF_MAC = "mac"
-CONF_METHOD = "method"
CONF_MAXIMUM = "maximum"
+CONF_METHOD = "method"
CONF_MINIMUM = "minimum"
CONF_MODE = "mode"
CONF_MONITORED_CONDITIONS = "monitored_conditions"
@@ -130,14 +134,18 @@ CONF_RADIUS = "radius"
CONF_RECIPIENT = "recipient"
CONF_REGION = "region"
CONF_RESOURCE = "resource"
-CONF_RESOURCES = "resources"
CONF_RESOURCE_TEMPLATE = "resource_template"
+CONF_RESOURCES = "resources"
CONF_RGB = "rgb"
CONF_ROOM = "room"
CONF_SCAN_INTERVAL = "scan_interval"
+CONF_SCENE = "scene"
CONF_SENDER = "sender"
CONF_SENSOR_TYPE = "sensor_type"
CONF_SENSORS = "sensors"
+CONF_SERVICE = "service"
+CONF_SERVICE_DATA = "data"
+CONF_SERVICE_TEMPLATE = "service_template"
CONF_SHOW_ON_MAP = "show_on_map"
CONF_SLAVE = "slave"
CONF_SOURCE = "source"
@@ -159,11 +167,12 @@ CONF_URL = "url"
CONF_USERNAME = "username"
CONF_VALUE_TEMPLATE = "value_template"
CONF_VERIFY_SSL = "verify_ssl"
+CONF_WAIT_TEMPLATE = "wait_template"
CONF_WEBHOOK_ID = "webhook_id"
CONF_WEEKDAY = "weekday"
+CONF_WHITE_VALUE = "white_value"
CONF_WHITELIST = "whitelist"
CONF_WHITELIST_EXTERNAL_DIRS = "whitelist_external_dirs"
-CONF_WHITE_VALUE = "white_value"
CONF_XY = "xy"
CONF_ZONE = "zone"
@@ -343,6 +352,17 @@ ENERGY_WATT_HOUR = "Wh"
TEMP_CELSIUS = "°C"
TEMP_FAHRENHEIT = "°F"
+# Time units
+TIME_MICROSECONDS = "μs"
+TIME_MILLISECONDS = "ms"
+TIME_SECONDS = "s"
+TIME_MINUTES = "min"
+TIME_HOURS = "h"
+TIME_DAYS = "d"
+TIME_WEEKS = "w"
+TIME_MONTHS = "m"
+TIME_YEARS = "y"
+
# Length units
LENGTH_CENTIMETERS: str = "cm"
LENGTH_METERS: str = "m"
@@ -364,13 +384,19 @@ PRESSURE_PSI: str = "psi"
# Volume units
VOLUME_LITERS: str = "L"
VOLUME_MILLILITERS: str = "mL"
+VOLUME_CUBIC_METERS = f"{LENGTH_METERS}³"
VOLUME_GALLONS: str = "gal"
VOLUME_FLUID_OUNCE: str = "fl. oz."
+# Area units
+AREA_SQUARE_METERS = f"{LENGTH_METERS}²"
+
# Mass units
MASS_GRAMS: str = "g"
MASS_KILOGRAMS: str = "kg"
+MASS_MILLIGRAMS = "mg"
+MASS_MICROGRAMS = "µg"
MASS_OUNCES: str = "oz"
MASS_POUNDS: str = "lb"
@@ -378,6 +404,22 @@ MASS_POUNDS: str = "lb"
# UV Index units
UNIT_UV_INDEX: str = "UV index"
+# Percentage units
+UNIT_PERCENTAGE = "%"
+# Irradiation units
+IRRADIATION_WATTS_PER_SQUARE_METER = f"{POWER_WATT}/{AREA_SQUARE_METERS}"
+
+# Concentration units
+CONCENTRATION_MICROGRAMS_PER_CUBIC_METER = f"{MASS_MICROGRAMS}/{VOLUME_CUBIC_METERS}"
+CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER = f"{MASS_MILLIGRAMS}/{VOLUME_CUBIC_METERS}"
+CONCENTRATION_PARTS_PER_MILLION = "ppm"
+CONCENTRATION_PARTS_PER_BILLION = "ppb"
+
+# Speed units
+SPEED_METERS_PER_SECOND = f"{LENGTH_METERS}/{TIME_SECONDS}"
+SPEED_KILOMETERS_PER_HOUR = f"{LENGTH_KILOMETERS}/{TIME_HOURS}"
+SPEED_MILES_PER_HOUR = "mph"
+
# Data units
DATA_BITS = "bit"
DATA_KILOBITS = "kbit"
@@ -400,17 +442,17 @@ DATA_PEBIBYTES = "PiB"
DATA_EXBIBYTES = "EiB"
DATA_ZEBIBYTES = "ZiB"
DATA_YOBIBYTES = "YiB"
-DATA_RATE_BITS_PER_SECOND = f"{DATA_BITS}/s"
-DATA_RATE_KILOBITS_PER_SECOND = f"{DATA_KILOBITS}/s"
-DATA_RATE_MEGABITS_PER_SECOND = f"{DATA_MEGABITS}/s"
-DATA_RATE_GIGABITS_PER_SECOND = f"{DATA_GIGABITS}/s"
-DATA_RATE_BYTES_PER_SECOND = f"{DATA_BYTES}/s"
-DATA_RATE_KILOBYTES_PER_SECOND = f"{DATA_KILOBYTES}/s"
-DATA_RATE_MEGABYTES_PER_SECOND = f"{DATA_MEGABYTES}/s"
-DATA_RATE_GIGABYTES_PER_SECOND = f"{DATA_GIGABYTES}/s"
-DATA_RATE_KIBIBYTES_PER_SECOND = f"{DATA_KIBIBYTES}/s"
-DATA_RATE_MEBIBYTES_PER_SECOND = f"{DATA_MEBIBYTES}/s"
-DATA_RATE_GIBIBYTES_PER_SECOND = f"{DATA_GIBIBYTES}/s"
+DATA_RATE_BITS_PER_SECOND = f"{DATA_BITS}/{TIME_SECONDS}"
+DATA_RATE_KILOBITS_PER_SECOND = f"{DATA_KILOBITS}/{TIME_SECONDS}"
+DATA_RATE_MEGABITS_PER_SECOND = f"{DATA_MEGABITS}/{TIME_SECONDS}"
+DATA_RATE_GIGABITS_PER_SECOND = f"{DATA_GIGABITS}/{TIME_SECONDS}"
+DATA_RATE_BYTES_PER_SECOND = f"{DATA_BYTES}/{TIME_SECONDS}"
+DATA_RATE_KILOBYTES_PER_SECOND = f"{DATA_KILOBYTES}/{TIME_SECONDS}"
+DATA_RATE_MEGABYTES_PER_SECOND = f"{DATA_MEGABYTES}/{TIME_SECONDS}"
+DATA_RATE_GIGABYTES_PER_SECOND = f"{DATA_GIGABYTES}/{TIME_SECONDS}"
+DATA_RATE_KIBIBYTES_PER_SECOND = f"{DATA_KIBIBYTES}/{TIME_SECONDS}"
+DATA_RATE_MEBIBYTES_PER_SECOND = f"{DATA_MEBIBYTES}/{TIME_SECONDS}"
+DATA_RATE_GIBIBYTES_PER_SECOND = f"{DATA_GIBIBYTES}/{TIME_SECONDS}"
# #### SERVICES ####
SERVICE_HOMEASSISTANT_STOP = "stop"
diff --git a/homeassistant/core.py b/homeassistant/core.py
index c17c1f698ce..a1d9a83d1ad 100644
--- a/homeassistant/core.py
+++ b/homeassistant/core.py
@@ -12,6 +12,7 @@ import functools
import logging
import os
import pathlib
+import re
import threading
from time import monotonic
from types import MappingProxyType
@@ -63,7 +64,7 @@ from homeassistant.exceptions import (
ServiceNotFound,
Unauthorized,
)
-from homeassistant.util import location, slugify
+from homeassistant.util import location
from homeassistant.util.async_ import fire_coroutine_threadsafe, run_callback_threadsafe
import homeassistant.util.dt as dt_util
from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem
@@ -103,12 +104,15 @@ def split_entity_id(entity_id: str) -> List[str]:
return entity_id.split(".", 1)
+VALID_ENTITY_ID = re.compile(r"^(?!.+__)(?!_)[\da-z_]+(? bool:
"""Test if an entity ID is a valid format.
Format: . where both are slugs.
"""
- return "." in entity_id and slugify(entity_id) == entity_id.replace(".", "_", 1)
+ return VALID_ENTITY_ID.match(entity_id) is not None
def valid_state(state: str) -> bool:
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 9173714a6f6..b281a322b23 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -9,9 +9,11 @@ FLOWS = [
"abode",
"adguard",
"airly",
+ "airvisual",
"almond",
"ambiclimate",
"ambient_station",
+ "august",
"axis",
"brother",
"cast",
@@ -21,6 +23,7 @@ FLOWS = [
"daikin",
"deconz",
"dialogflow",
+ "directv",
"dynalite",
"ecobee",
"elgato",
@@ -34,6 +37,7 @@ FLOWS = [
"gios",
"glances",
"gpslogger",
+ "griddy",
"hangouts",
"heos",
"hisense_aehw4a1",
@@ -78,7 +82,9 @@ FLOWS = [
"rainmachine",
"ring",
"samsungtv",
+ "sense",
"sentry",
+ "shopping_list",
"simplisafe",
"smartthings",
"smhi",
diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py
index 0eb9af0231d..3bf54b1d9f7 100644
--- a/homeassistant/generated/ssdp.py
+++ b/homeassistant/generated/ssdp.py
@@ -11,6 +11,12 @@ SSDP = {
"manufacturer": "Royal Philips Electronics"
}
],
+ "directv": [
+ {
+ "deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
+ "manufacturer": "DIRECTV"
+ }
+ ],
"heos": [
{
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py
index 025c6c07dee..8234dd6ec87 100644
--- a/homeassistant/helpers/collection.py
+++ b/homeassistant/helpers/collection.py
@@ -31,11 +31,11 @@ ChangeListener = Callable[
str,
# Item ID
str,
- # New config (None if removed)
- Optional[dict],
+ # New or removed config
+ dict,
],
Awaitable[None],
-] # pylint: disable=invalid-name
+]
class CollectionError(HomeAssistantError):
@@ -104,9 +104,7 @@ class ObservableCollection(ABC):
"""
self.listeners.append(listener)
- async def notify_change(
- self, change_type: str, item_id: str, item: Optional[dict]
- ) -> None:
+ async def notify_change(self, change_type: str, item_id: str, item: dict) -> None:
"""Notify listeners of a change."""
self.logger.debug("%s %s: %s", change_type, item_id, item)
for listener in self.listeners:
@@ -136,8 +134,8 @@ class YamlCollection(ObservableCollection):
await self.notify_change(event, item_id, item)
for item_id in old_ids:
- self.data.pop(item_id)
- await self.notify_change(CHANGE_REMOVED, item_id, None)
+
+ await self.notify_change(CHANGE_REMOVED, item_id, self.data.pop(item_id))
class StorageCollection(ObservableCollection):
@@ -219,10 +217,10 @@ class StorageCollection(ObservableCollection):
if item_id not in self.data:
raise ItemNotFound(item_id)
- self.data.pop(item_id)
+ item = self.data.pop(item_id)
self._async_schedule_save()
- await self.notify_change(CHANGE_REMOVED, item_id, None)
+ await self.notify_change(CHANGE_REMOVED, item_id, item)
@callback
def _async_schedule_save(self) -> None:
@@ -235,6 +233,26 @@ class StorageCollection(ObservableCollection):
return {"items": list(self.data.values())}
+class IDLessCollection(ObservableCollection):
+ """A collection without IDs."""
+
+ counter = 0
+
+ async def async_load(self, data: List[dict]) -> None:
+ """Load the collection. Overrides existing data."""
+ for item_id, item in list(self.data.items()):
+ await self.notify_change(CHANGE_REMOVED, item_id, item)
+
+ self.data.clear()
+
+ for item in data:
+ self.counter += 1
+ item_id = f"fakeid-{self.counter}"
+
+ self.data[item_id] = item
+ await self.notify_change(CHANGE_ADDED, item_id, item)
+
+
@callback
def attach_entity_component_collection(
entity_component: EntityComponent,
@@ -244,12 +262,10 @@ def attach_entity_component_collection(
"""Map a collection to an entity component."""
entities = {}
- async def _collection_changed(
- change_type: str, item_id: str, config: Optional[dict]
- ) -> None:
+ async def _collection_changed(change_type: str, item_id: str, config: dict) -> None:
"""Handle a collection change."""
if change_type == CHANGE_ADDED:
- entity = create_entity(cast(dict, config))
+ entity = create_entity(config)
await entity_component.async_add_entities([entity]) # type: ignore
entities[item_id] = entity
return
@@ -274,9 +290,7 @@ def attach_entity_registry_cleaner(
) -> None:
"""Attach a listener to clean up entity registry on collection changes."""
- async def _collection_changed(
- change_type: str, item_id: str, config: Optional[Dict]
- ) -> None:
+ async def _collection_changed(change_type: str, item_id: str, config: Dict) -> None:
"""Handle a collection change: clean up entity registry on removals."""
if change_type != CHANGE_REMOVED:
return
diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py
index 1ff2644fa58..db966d93412 100644
--- a/homeassistant/helpers/config_validation.py
+++ b/homeassistant/helpers/config_validation.py
@@ -39,18 +39,27 @@ from homeassistant.const import (
CONF_ALIAS,
CONF_BELOW,
CONF_CONDITION,
+ CONF_CONTINUE_ON_TIMEOUT,
+ CONF_DELAY,
CONF_DEVICE_ID,
CONF_DOMAIN,
CONF_ENTITY_ID,
CONF_ENTITY_NAMESPACE,
+ CONF_EVENT,
+ CONF_EVENT_DATA,
+ CONF_EVENT_DATA_TEMPLATE,
CONF_FOR,
CONF_PLATFORM,
CONF_SCAN_INTERVAL,
+ CONF_SCENE,
+ CONF_SERVICE,
+ CONF_SERVICE_TEMPLATE,
CONF_STATE,
CONF_TIMEOUT,
CONF_UNIT_SYSTEM_IMPERIAL,
CONF_UNIT_SYSTEM_METRIC,
CONF_VALUE_TEMPLATE,
+ CONF_WAIT_TEMPLATE,
ENTITY_MATCH_ALL,
ENTITY_MATCH_NONE,
SUN_EVENT_SUNRISE,
@@ -402,7 +411,20 @@ def service(value: Any) -> str:
raise vol.Invalid(f"Service {value} does not match format .")
-def schema_with_slug_keys(value_schema: Union[T, Callable]) -> Callable:
+def slug(value: Any) -> str:
+ """Validate value is a valid slug."""
+ if value is None:
+ raise vol.Invalid("Slug should not be None")
+ str_value = str(value)
+ slg = util_slugify(str_value)
+ if str_value == slg:
+ return str_value
+ raise vol.Invalid(f"invalid slug {value} (try {slg})")
+
+
+def schema_with_slug_keys(
+ value_schema: Union[T, Callable], *, slug_validator: Callable[[Any], str] = slug
+) -> Callable:
"""Ensure dicts have slugs as keys.
Replacement of vol.Schema({cv.slug: value_schema}) to prevent misleading
@@ -416,24 +438,13 @@ def schema_with_slug_keys(value_schema: Union[T, Callable]) -> Callable:
raise vol.Invalid("expected dictionary")
for key in value.keys():
- slug(key)
+ slug_validator(key)
return cast(Dict, schema(value))
return verify
-def slug(value: Any) -> str:
- """Validate value is a valid slug."""
- if value is None:
- raise vol.Invalid("Slug should not be None")
- str_value = str(value)
- slg = util_slugify(str_value)
- if str_value == slg:
- return str_value
- raise vol.Invalid(f"invalid slug {value} (try {slg})")
-
-
def slugify(value: Any) -> str:
"""Coerce a value to a slug."""
if value is None:
@@ -704,6 +715,30 @@ def deprecated(
return validator
+def key_value_schemas(
+ key: str, value_schemas: Dict[str, vol.Schema]
+) -> Callable[[Any], Dict[str, Any]]:
+ """Create a validator that validates based on a value for specific key.
+
+ This gives better error messages.
+ """
+
+ def key_value_validator(value: Any) -> Dict[str, Any]:
+ if not isinstance(value, dict):
+ raise vol.Invalid("Expected a dictionary")
+
+ key_value = value.get(key)
+
+ if key_value not in value_schemas:
+ raise vol.Invalid(
+ f"Unexpected value for {key}: '{key_value}'. Expected {', '.join(value_schemas)}"
+ )
+
+ return cast(Dict[str, Any], value_schemas[key_value](value))
+
+ return key_value_validator
+
+
# Validator helpers
@@ -774,9 +809,9 @@ def make_entity_service_schema(
EVENT_SCHEMA = vol.Schema(
{
vol.Optional(CONF_ALIAS): string,
- vol.Required("event"): string,
- vol.Optional("event_data"): dict,
- vol.Optional("event_data_template"): {match_all: template_complex},
+ vol.Required(CONF_EVENT): string,
+ vol.Optional(CONF_EVENT_DATA): dict,
+ vol.Optional(CONF_EVENT_DATA_TEMPLATE): {match_all: template_complex},
}
)
@@ -784,14 +819,14 @@ SERVICE_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(CONF_ALIAS): string,
- vol.Exclusive("service", "service name"): service,
- vol.Exclusive("service_template", "service name"): template,
+ vol.Exclusive(CONF_SERVICE, "service name"): service,
+ vol.Exclusive(CONF_SERVICE_TEMPLATE, "service name"): template,
vol.Optional("data"): dict,
vol.Optional("data_template"): {match_all: template_complex},
vol.Optional(CONF_ENTITY_ID): comp_entity_ids,
}
),
- has_at_least_one_key("service", "service_template"),
+ has_at_least_one_key(CONF_SERVICE, CONF_SERVICE_TEMPLATE),
)
NUMERIC_STATE_CONDITION_SCHEMA = vol.All(
@@ -899,22 +934,25 @@ DEVICE_CONDITION_BASE_SCHEMA = vol.Schema(
DEVICE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA)
-CONDITION_SCHEMA: vol.Schema = vol.Any(
- NUMERIC_STATE_CONDITION_SCHEMA,
- STATE_CONDITION_SCHEMA,
- SUN_CONDITION_SCHEMA,
- TEMPLATE_CONDITION_SCHEMA,
- TIME_CONDITION_SCHEMA,
- ZONE_CONDITION_SCHEMA,
- AND_CONDITION_SCHEMA,
- OR_CONDITION_SCHEMA,
- DEVICE_CONDITION_SCHEMA,
+CONDITION_SCHEMA: vol.Schema = key_value_schemas(
+ CONF_CONDITION,
+ {
+ "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA,
+ "state": STATE_CONDITION_SCHEMA,
+ "sun": SUN_CONDITION_SCHEMA,
+ "template": TEMPLATE_CONDITION_SCHEMA,
+ "time": TIME_CONDITION_SCHEMA,
+ "zone": ZONE_CONDITION_SCHEMA,
+ "and": AND_CONDITION_SCHEMA,
+ "or": OR_CONDITION_SCHEMA,
+ "device": DEVICE_CONDITION_SCHEMA,
+ },
)
_SCRIPT_DELAY_SCHEMA = vol.Schema(
{
vol.Optional(CONF_ALIAS): string,
- vol.Required("delay"): vol.Any(
+ vol.Required(CONF_DELAY): vol.Any(
vol.All(time_period, positive_timedelta), template, template_complex
),
}
@@ -923,9 +961,9 @@ _SCRIPT_DELAY_SCHEMA = vol.Schema(
_SCRIPT_WAIT_TEMPLATE_SCHEMA = vol.Schema(
{
vol.Optional(CONF_ALIAS): string,
- vol.Required("wait_template"): template,
+ vol.Required(CONF_WAIT_TEMPLATE): template,
vol.Optional(CONF_TIMEOUT): vol.All(time_period, positive_timedelta),
- vol.Optional("continue_on_timeout"): boolean,
+ vol.Optional(CONF_CONTINUE_ON_TIMEOUT): boolean,
}
)
@@ -935,19 +973,57 @@ DEVICE_ACTION_BASE_SCHEMA = vol.Schema(
DEVICE_ACTION_SCHEMA = DEVICE_ACTION_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA)
-_SCRIPT_SCENE_SCHEMA = vol.Schema({vol.Required("scene"): entity_domain("scene")})
+_SCRIPT_SCENE_SCHEMA = vol.Schema({vol.Required(CONF_SCENE): entity_domain("scene")})
-SCRIPT_SCHEMA = vol.All(
- ensure_list,
- [
- vol.Any(
- SERVICE_SCHEMA,
- _SCRIPT_DELAY_SCHEMA,
- _SCRIPT_WAIT_TEMPLATE_SCHEMA,
- EVENT_SCHEMA,
- CONDITION_SCHEMA,
- DEVICE_ACTION_SCHEMA,
- _SCRIPT_SCENE_SCHEMA,
- )
- ],
-)
+SCRIPT_ACTION_DELAY = "delay"
+SCRIPT_ACTION_WAIT_TEMPLATE = "wait_template"
+SCRIPT_ACTION_CHECK_CONDITION = "condition"
+SCRIPT_ACTION_FIRE_EVENT = "event"
+SCRIPT_ACTION_CALL_SERVICE = "call_service"
+SCRIPT_ACTION_DEVICE_AUTOMATION = "device"
+SCRIPT_ACTION_ACTIVATE_SCENE = "scene"
+
+
+def determine_script_action(action: dict) -> str:
+ """Determine action type."""
+ if CONF_DELAY in action:
+ return SCRIPT_ACTION_DELAY
+
+ if CONF_WAIT_TEMPLATE in action:
+ return SCRIPT_ACTION_WAIT_TEMPLATE
+
+ if CONF_CONDITION in action:
+ return SCRIPT_ACTION_CHECK_CONDITION
+
+ if CONF_EVENT in action:
+ return SCRIPT_ACTION_FIRE_EVENT
+
+ if CONF_DEVICE_ID in action:
+ return SCRIPT_ACTION_DEVICE_AUTOMATION
+
+ if CONF_SCENE in action:
+ return SCRIPT_ACTION_ACTIVATE_SCENE
+
+ return SCRIPT_ACTION_CALL_SERVICE
+
+
+ACTION_TYPE_SCHEMAS: Dict[str, Callable[[Any], dict]] = {
+ SCRIPT_ACTION_CALL_SERVICE: SERVICE_SCHEMA,
+ SCRIPT_ACTION_DELAY: _SCRIPT_DELAY_SCHEMA,
+ SCRIPT_ACTION_WAIT_TEMPLATE: _SCRIPT_WAIT_TEMPLATE_SCHEMA,
+ SCRIPT_ACTION_FIRE_EVENT: EVENT_SCHEMA,
+ SCRIPT_ACTION_CHECK_CONDITION: CONDITION_SCHEMA,
+ SCRIPT_ACTION_DEVICE_AUTOMATION: DEVICE_ACTION_SCHEMA,
+ SCRIPT_ACTION_ACTIVATE_SCENE: _SCRIPT_SCENE_SCHEMA,
+}
+
+
+def script_action(value: Any) -> dict:
+ """Validate a script action."""
+ if not isinstance(value, dict):
+ raise vol.Invalid("expected dictionary")
+
+ return ACTION_TYPE_SCHEMAS[determine_script_action(value)](value)
+
+
+SCRIPT_SCHEMA = vol.All(ensure_list, [script_action])
diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py
index bbaf6dacfeb..6206081dc8c 100644
--- a/homeassistant/helpers/debounce.py
+++ b/homeassistant/helpers/debounce.py
@@ -31,6 +31,7 @@ class Debouncer:
self.immediate = immediate
self._timer_task: Optional[asyncio.TimerHandle] = None
self._execute_at_end_of_timer: bool = False
+ self._execute_lock = asyncio.Lock()
async def async_call(self) -> None:
"""Call the function."""
@@ -42,15 +43,23 @@ class Debouncer:
return
- if self.immediate:
- await self.hass.async_add_job(self.function) # type: ignore
- else:
- self._execute_at_end_of_timer = True
+ # Locked means a call is in progress. Any call is good, so abort.
+ if self._execute_lock.locked():
+ return
- self._timer_task = self.hass.loop.call_later(
- self.cooldown,
- lambda: self.hass.async_create_task(self._handle_timer_finish()),
- )
+ if not self.immediate:
+ self._execute_at_end_of_timer = True
+ self._schedule_timer()
+ return
+
+ async with self._execute_lock:
+ # Abort if timer got set while we're waiting for the lock.
+ if self._timer_task:
+ return
+
+ await self.hass.async_add_job(self.function) # type: ignore
+
+ self._schedule_timer()
async def _handle_timer_finish(self) -> None:
"""Handle a finished timer."""
@@ -63,10 +72,21 @@ class Debouncer:
self._execute_at_end_of_timer = False
- try:
- await self.hass.async_add_job(self.function) # type: ignore
- except Exception: # pylint: disable=broad-except
- self.logger.exception("Unexpected exception from %s", self.function)
+ # Locked means a call is in progress. Any call is good, so abort.
+ if self._execute_lock.locked():
+ return
+
+ async with self._execute_lock:
+ # Abort if timer got set while we're waiting for the lock.
+ if self._timer_task:
+ return # type: ignore
+
+ try:
+ await self.hass.async_add_job(self.function) # type: ignore
+ except Exception: # pylint: disable=broad-except
+ self.logger.exception("Unexpected exception from %s", self.function)
+
+ self._schedule_timer()
@callback
def async_cancel(self) -> None:
@@ -76,3 +96,11 @@ class Debouncer:
self._timer_task = None
self._execute_at_end_of_timer = False
+
+ @callback
+ def _schedule_timer(self) -> None:
+ """Schedule a timer."""
+ self._timer_task = self.hass.loop.call_later(
+ self.cooldown,
+ lambda: self.hass.async_create_task(self._handle_timer_finish()),
+ )
diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py
index 0821b909dc7..6d9574c3bbd 100644
--- a/homeassistant/helpers/device_registry.py
+++ b/homeassistant/helpers/device_registry.py
@@ -155,6 +155,7 @@ class DeviceRegistry:
name=_UNDEF,
name_by_user=_UNDEF,
new_identifiers=_UNDEF,
+ sw_version=_UNDEF,
via_device_id=_UNDEF,
remove_config_entry_id=_UNDEF,
):
@@ -165,6 +166,7 @@ class DeviceRegistry:
name=name,
name_by_user=name_by_user,
new_identifiers=new_identifiers,
+ sw_version=sw_version,
via_device_id=via_device_id,
remove_config_entry_id=remove_config_entry_id,
)
diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py
index 49ed0f4a567..186aecd78f4 100644
--- a/homeassistant/helpers/entity.py
+++ b/homeassistant/helpers/entity.py
@@ -488,6 +488,12 @@ class Entity(ABC):
self._on_remove = []
self._on_remove.append(func)
+ async def async_removed_from_registry(self) -> None:
+ """Run when entity has been removed from entity registry.
+
+ To be extended by integrations.
+ """
+
async def async_remove(self) -> None:
"""Remove entity from Home Assistant."""
assert self.hass is not None
@@ -534,6 +540,9 @@ class Entity(ABC):
async def _async_registry_updated(self, event):
"""Handle entity registry update."""
data = event.data
+ if data["action"] == "remove" and data["entity_id"] == self.entity_id:
+ await self.async_removed_from_registry()
+
if (
data["action"] != "update"
or data.get("old_entity_id", data["entity_id"]) != self.entity_id
diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py
index 1cac4679d82..937a675aada 100644
--- a/homeassistant/helpers/script.py
+++ b/homeassistant/helpers/script.py
@@ -1,10 +1,11 @@
"""Helpers to execute scripts."""
+from abc import ABC, abstractmethod
import asyncio
from contextlib import suppress
from datetime import datetime
from itertools import islice
import logging
-from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple
+from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, cast
import voluptuous as vol
@@ -14,9 +15,16 @@ import homeassistant.components.scene as scene
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_CONDITION,
+ CONF_CONTINUE_ON_TIMEOUT,
+ CONF_DELAY,
CONF_DEVICE_ID,
CONF_DOMAIN,
+ CONF_EVENT,
+ CONF_EVENT_DATA,
+ CONF_EVENT_DATA_TEMPLATE,
+ CONF_SCENE,
CONF_TIMEOUT,
+ CONF_WAIT_TEMPLATE,
SERVICE_TURN_ON,
)
from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback
@@ -31,80 +39,53 @@ from homeassistant.helpers.event import (
async_track_template,
)
from homeassistant.helpers.typing import ConfigType
-from homeassistant.util.async_ import run_callback_threadsafe
-import homeassistant.util.dt as date_util
+from homeassistant.util.dt import utcnow
# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs
-_LOGGER = logging.getLogger(__name__)
-
CONF_ALIAS = "alias"
-CONF_SERVICE = "service"
-CONF_SERVICE_DATA = "data"
-CONF_SEQUENCE = "sequence"
-CONF_EVENT = "event"
-CONF_EVENT_DATA = "event_data"
-CONF_EVENT_DATA_TEMPLATE = "event_data_template"
-CONF_DELAY = "delay"
-CONF_WAIT_TEMPLATE = "wait_template"
-CONF_CONTINUE = "continue_on_timeout"
-CONF_SCENE = "scene"
+IF_RUNNING_ERROR = "error"
+IF_RUNNING_IGNORE = "ignore"
+IF_RUNNING_PARALLEL = "parallel"
+IF_RUNNING_RESTART = "restart"
+# First choice is default
+IF_RUNNING_CHOICES = [
+ IF_RUNNING_PARALLEL,
+ IF_RUNNING_ERROR,
+ IF_RUNNING_IGNORE,
+ IF_RUNNING_RESTART,
+]
-ACTION_DELAY = "delay"
-ACTION_WAIT_TEMPLATE = "wait_template"
-ACTION_CHECK_CONDITION = "condition"
-ACTION_FIRE_EVENT = "event"
-ACTION_CALL_SERVICE = "call_service"
-ACTION_DEVICE_AUTOMATION = "device"
-ACTION_ACTIVATE_SCENE = "scene"
+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,
+]
-
-def _determine_action(action):
- """Determine action type."""
- if CONF_DELAY in action:
- return ACTION_DELAY
-
- if CONF_WAIT_TEMPLATE in action:
- return ACTION_WAIT_TEMPLATE
-
- if CONF_CONDITION in action:
- return ACTION_CHECK_CONDITION
-
- if CONF_EVENT in action:
- return ACTION_FIRE_EVENT
-
- if CONF_DEVICE_ID in action:
- return ACTION_DEVICE_AUTOMATION
-
- if CONF_SCENE in action:
- return ACTION_ACTIVATE_SCENE
-
- return ACTION_CALL_SERVICE
-
-
-def call_from_config(
- hass: HomeAssistant,
- config: ConfigType,
- variables: Optional[Sequence] = None,
- context: Optional[Context] = None,
-) -> None:
- """Call a script based on a config entry."""
- Script(hass, cv.SCRIPT_SCHEMA(config)).run(variables, context)
+_LOG_EXCEPTION = logging.ERROR + 1
+_TIMEOUT_MSG = "Timeout reached, abort script."
async def async_validate_action_config(
hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
- action_type = _determine_action(config)
+ action_type = cv.determine_script_action(config)
- if action_type == ACTION_DEVICE_AUTOMATION:
+ if action_type == cv.SCRIPT_ACTION_DEVICE_AUTOMATION:
platform = await device_automation.async_get_device_automation_platform(
hass, config[CONF_DOMAIN], "action"
)
config = platform.ACTION_SCHEMA(config) # type: ignore
- if action_type == ACTION_CHECK_CONDITION and config[CONF_CONDITION] == "device":
+ if (
+ action_type == cv.SCRIPT_ACTION_CHECK_CONDITION
+ and config[CONF_CONDITION] == "device"
+ ):
platform = await device_automation.async_get_device_automation_platform(
hass, config[CONF_DOMAIN], "condition"
)
@@ -121,6 +102,448 @@ class _SuspendScript(Exception):
"""Throw if script needs to suspend."""
+class _ScriptRunBase(ABC):
+ """Common data & methods for managing Script sequence run."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ script: "Script",
+ variables: Optional[Sequence],
+ context: Optional[Context],
+ log_exceptions: bool,
+ ) -> None:
+ self._hass = hass
+ self._script = script
+ self._variables = variables
+ self._context = context
+ self._log_exceptions = log_exceptions
+ self._step = -1
+ self._action: Optional[Dict[str, Any]] = None
+
+ def _changed(self):
+ self._script._changed() # pylint: disable=protected-access
+
+ @property
+ def _config_cache(self):
+ return self._script._config_cache # pylint: disable=protected-access
+
+ @abstractmethod
+ async def async_run(self) -> None:
+ """Run script."""
+
+ async def _async_step(self, log_exceptions):
+ try:
+ 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)
+ raise
+
+ @abstractmethod
+ async def async_stop(self) -> None:
+ """Stop script run."""
+
+ def _log_exception(self, exception):
+ action_type = cv.determine_script_action(self._action)
+
+ error = str(exception)
+ level = logging.ERROR
+
+ if isinstance(exception, vol.Invalid):
+ error_desc = "Invalid data"
+
+ elif isinstance(exception, exceptions.TemplateError):
+ error_desc = "Error rendering template"
+
+ elif isinstance(exception, exceptions.Unauthorized):
+ error_desc = "Unauthorized"
+
+ elif isinstance(exception, exceptions.ServiceNotFound):
+ error_desc = "Service not found"
+
+ else:
+ error_desc = "Unexpected error"
+ level = _LOG_EXCEPTION
+
+ self._log(
+ "Error executing script. %s for %s at pos %s: %s",
+ error_desc,
+ action_type,
+ self._step + 1,
+ error,
+ level=level,
+ )
+
+ @abstractmethod
+ async def _async_delay_step(self):
+ """Handle delay."""
+
+ def _prep_delay_step(self):
+ try:
+ delay = vol.All(cv.time_period, cv.positive_timedelta)(
+ template.render_complex(self._action[CONF_DELAY], self._variables)
+ )
+ except (exceptions.TemplateError, vol.Invalid) as ex:
+ self._raise(
+ "Error rendering %s delay template: %s",
+ self._script.name,
+ ex,
+ exception=_StopScript,
+ )
+
+ self._script.last_action = self._action.get(CONF_ALIAS, f"delay {delay}")
+ self._log("Executing step %s", self._script.last_action)
+
+ return delay
+
+ @abstractmethod
+ async def _async_wait_template_step(self):
+ """Handle a wait template."""
+
+ def _prep_wait_template_step(self, async_script_wait):
+ wait_template = self._action[CONF_WAIT_TEMPLATE]
+ wait_template.hass = self._hass
+
+ self._script.last_action = self._action.get(CONF_ALIAS, "wait template")
+ self._log("Executing step %s", self._script.last_action)
+
+ # check if condition already okay
+ if condition.async_template(self._hass, wait_template, self._variables):
+ return None
+
+ return async_track_template(
+ self._hass, wait_template, async_script_wait, self._variables
+ )
+
+ async def _async_call_service_step(self):
+ """Call the service specified in the action."""
+ self._script.last_action = self._action.get(CONF_ALIAS, "call service")
+ self._log("Executing step %s", self._script.last_action)
+ await service.async_call_from_config(
+ self._hass,
+ self._action,
+ blocking=True,
+ variables=self._variables,
+ validate_config=False,
+ context=self._context,
+ )
+
+ async def _async_device_step(self):
+ """Perform the device automation specified in the action."""
+ self._script.last_action = self._action.get(CONF_ALIAS, "device automation")
+ self._log("Executing step %s", self._script.last_action)
+ platform = await device_automation.async_get_device_automation_platform(
+ self._hass, self._action[CONF_DOMAIN], "action"
+ )
+ await platform.async_call_action_from_config(
+ self._hass, self._action, self._variables, self._context
+ )
+
+ async def _async_scene_step(self):
+ """Activate the scene specified in the action."""
+ self._script.last_action = self._action.get(CONF_ALIAS, "activate scene")
+ self._log("Executing step %s", self._script.last_action)
+ await self._hass.services.async_call(
+ scene.DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: self._action[CONF_SCENE]},
+ blocking=True,
+ context=self._context,
+ )
+
+ async def _async_event_step(self):
+ """Fire an event."""
+ self._script.last_action = self._action.get(
+ CONF_ALIAS, self._action[CONF_EVENT]
+ )
+ self._log("Executing step %s", self._script.last_action)
+ event_data = dict(self._action.get(CONF_EVENT_DATA, {}))
+ if CONF_EVENT_DATA_TEMPLATE in self._action:
+ try:
+ event_data.update(
+ template.render_complex(
+ self._action[CONF_EVENT_DATA_TEMPLATE], self._variables
+ )
+ )
+ except exceptions.TemplateError as ex:
+ self._log(
+ "Error rendering event data template: %s", ex, level=logging.ERROR
+ )
+
+ self._hass.bus.async_fire(
+ self._action[CONF_EVENT], event_data, context=self._context
+ )
+
+ async def _async_condition_step(self):
+ """Test if condition is matching."""
+ config_cache_key = frozenset((k, str(v)) for k, v in self._action.items())
+ config = self._config_cache.get(config_cache_key)
+ if not config:
+ config = await condition.async_from_config(self._hass, self._action, False)
+ self._config_cache[config_cache_key] = config
+
+ self._script.last_action = self._action.get(
+ CONF_ALIAS, self._action[CONF_CONDITION]
+ )
+ check = config(self._hass, self._variables)
+ self._log("Test condition %s: %s", self._script.last_action, check)
+ if not check:
+ raise _StopScript
+
+ def _log(self, msg, *args, level=logging.INFO):
+ self._script._log(msg, *args, level=level) # pylint: disable=protected-access
+
+ def _raise(self, msg, *args, exception=None):
+ # pylint: disable=protected-access
+ self._script._raise(msg, *args, exception=exception)
+
+
+class _ScriptRun(_ScriptRunBase):
+ """Manage Script sequence run."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ script: "Script",
+ variables: Optional[Sequence],
+ context: Optional[Context],
+ log_exceptions: bool,
+ ) -> None:
+ super().__init__(hass, script, variables, context, log_exceptions)
+ self._stop = asyncio.Event()
+ self._stopped = asyncio.Event()
+
+ async def _async_run(self, propagate_exceptions=True):
+ self._log("Running script")
+ try:
+ for self._step, self._action in enumerate(self._script.sequence):
+ if self._stop.is_set():
+ break
+ await self._async_step(not propagate_exceptions)
+ except _StopScript:
+ pass
+ except Exception: # pylint: disable=broad-except
+ if propagate_exceptions:
+ raise
+ finally:
+ if not self._stop.is_set():
+ self._changed()
+ self._script.last_action = None
+ self._script._runs.remove(self) # pylint: disable=protected-access
+ self._stopped.set()
+
+ async def async_stop(self) -> None:
+ """Stop script run."""
+ self._stop.set()
+ await self._stopped.wait()
+
+ async def _async_delay_step(self):
+ """Handle delay."""
+ timeout = self._prep_delay_step().total_seconds()
+ if not self._stop.is_set():
+ self._changed()
+ await asyncio.wait({self._stop.wait()}, timeout=timeout)
+
+ async def _async_wait_template_step(self):
+ """Handle a wait template."""
+
+ @callback
+ def async_script_wait(entity_id, from_s, to_s):
+ """Handle script after template condition is true."""
+ done.set()
+
+ unsub = self._prep_wait_template_step(async_script_wait)
+ if not unsub:
+ return
+
+ if not self._stop.is_set():
+ self._changed()
+ try:
+ timeout = self._action[CONF_TIMEOUT].total_seconds()
+ except KeyError:
+ timeout = None
+ done = asyncio.Event()
+ try:
+ await asyncio.wait_for(
+ asyncio.wait(
+ {self._stop.wait(), done.wait()},
+ return_when=asyncio.FIRST_COMPLETED,
+ ),
+ timeout,
+ )
+ except asyncio.TimeoutError:
+ if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True):
+ self._log(_TIMEOUT_MSG)
+ raise _StopScript
+ finally:
+ unsub()
+
+
+class _BackgroundScriptRun(_ScriptRun):
+ """Manage background Script sequence run."""
+
+ async def async_run(self) -> None:
+ """Run script."""
+ self._hass.async_create_task(self._async_run(False))
+
+
+class _BlockingScriptRun(_ScriptRun):
+ """Manage blocking Script sequence run."""
+
+ async def async_run(self) -> None:
+ """Run script."""
+ try:
+ await asyncio.shield(self._async_run())
+ except asyncio.CancelledError:
+ await self.async_stop()
+ raise
+
+
+class _LegacyScriptRun(_ScriptRunBase):
+ """Manage legacy Script sequence run."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ script: "Script",
+ variables: Optional[Sequence],
+ context: Optional[Context],
+ log_exceptions: bool,
+ shared: Optional["_LegacyScriptRun"],
+ ) -> None:
+ super().__init__(hass, script, variables, context, log_exceptions)
+ if shared:
+ self._shared = shared
+ else:
+ # To implement legacy behavior we need to share the following "run state"
+ # amongst all runs, so it will only exist in the first instantiation of
+ # concurrent runs, and the rest will use it, too.
+ self._current = -1
+ self._async_listeners: List[CALLBACK_TYPE] = []
+ self._shared = self
+
+ @property
+ def _cur(self):
+ return self._shared._current # pylint: disable=protected-access
+
+ @_cur.setter
+ def _cur(self, value):
+ self._shared._current = value # pylint: disable=protected-access
+
+ @property
+ def _async_listener(self):
+ return self._shared._async_listeners # pylint: disable=protected-access
+
+ async def async_run(self) -> None:
+ """Run script."""
+ await self._async_run()
+
+ async def _async_run(self, propagate_exceptions=True):
+ if self._cur == -1:
+ self._log("Running script")
+ self._cur = 0
+
+ # Unregister callback if we were in a delay or wait but turn on is
+ # called again. In that case we just continue execution.
+ self._async_remove_listener()
+
+ suspended = False
+ try:
+ for self._step, self._action in islice(
+ enumerate(self._script.sequence), self._cur, None
+ ):
+ await self._async_step(not propagate_exceptions)
+ except _StopScript:
+ pass
+ except _SuspendScript:
+ # Store next step to take and notify change listeners
+ self._cur = self._step + 1
+ suspended = True
+ return
+ except Exception: # pylint: disable=broad-except
+ if propagate_exceptions:
+ raise
+ finally:
+ if self._cur != -1:
+ self._changed()
+ if not suspended:
+ self._script.last_action = None
+ await self.async_stop()
+
+ async def async_stop(self) -> None:
+ """Stop script run."""
+ if self._cur == -1:
+ return
+
+ self._cur = -1
+ self._async_remove_listener()
+ self._script._runs.clear() # pylint: disable=protected-access
+
+ async def _async_delay_step(self):
+ """Handle delay."""
+ delay = self._prep_delay_step()
+
+ @callback
+ def async_script_delay(now):
+ """Handle delay."""
+ with suppress(ValueError):
+ self._async_listener.remove(unsub)
+ self._hass.async_create_task(self._async_run(False))
+
+ unsub = async_track_point_in_utc_time(
+ self._hass, async_script_delay, utcnow() + delay
+ )
+ self._async_listener.append(unsub)
+ raise _SuspendScript
+
+ async def _async_wait_template_step(self):
+ """Handle a wait template."""
+
+ @callback
+ def async_script_wait(entity_id, from_s, to_s):
+ """Handle script after template condition is true."""
+ self._async_remove_listener()
+ self._hass.async_create_task(self._async_run(False))
+
+ @callback
+ def async_script_timeout(now):
+ """Call after timeout is retrieve."""
+ with suppress(ValueError):
+ self._async_listener.remove(unsub)
+
+ # Check if we want to continue to execute
+ # the script after the timeout
+ if self._action.get(CONF_CONTINUE_ON_TIMEOUT, True):
+ self._hass.async_create_task(self._async_run(False))
+ else:
+ self._log(_TIMEOUT_MSG)
+ self._hass.async_create_task(self.async_stop())
+
+ unsub_wait = self._prep_wait_template_step(async_script_wait)
+ if not unsub_wait:
+ return
+ self._async_listener.append(unsub_wait)
+
+ if CONF_TIMEOUT in self._action:
+ unsub = async_track_point_in_utc_time(
+ self._hass, async_script_timeout, utcnow() + self._action[CONF_TIMEOUT]
+ )
+ self._async_listener.append(unsub)
+
+ raise _SuspendScript
+
+ def _async_remove_listener(self):
+ """Remove listeners, if any."""
+ for unsub in self._async_listener:
+ unsub()
+ self._async_listener.clear()
+
+
class Script:
"""Representation of a script."""
@@ -130,39 +553,46 @@ 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,
+ logger: Optional[logging.Logger] = None,
+ log_exceptions: bool = True,
) -> None:
"""Initialize the script."""
- self.hass = hass
+ 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._cur = -1
- self._exception_step: Optional[int] = None
self.last_action = None
self.last_triggered: Optional[datetime] = None
self.can_cancel = any(
CONF_DELAY in action or CONF_WAIT_TEMPLATE in action
for action in self.sequence
)
- self._async_listener: List[CALLBACK_TYPE] = []
+ if not if_running and not run_mode:
+ self._if_running = IF_RUNNING_PARALLEL
+ self._run_mode = RUN_MODE_LEGACY
+ elif if_running and run_mode == RUN_MODE_LEGACY:
+ self._raise('Cannot use if_running if run_mode is "legacy"')
+ else:
+ self._if_running = if_running or IF_RUNNING_CHOICES[0]
+ self._run_mode = run_mode or RUN_MODE_CHOICES[0]
+ self._runs: List[_ScriptRunBase] = []
+ self._log_exceptions = log_exceptions
self._config_cache: Dict[Set[Tuple], Callable[..., bool]] = {}
- self._actions = {
- ACTION_DELAY: self._async_delay,
- ACTION_WAIT_TEMPLATE: self._async_wait_template,
- ACTION_CHECK_CONDITION: self._async_check_condition,
- ACTION_FIRE_EVENT: self._async_fire_event,
- ACTION_CALL_SERVICE: self._async_call_service,
- ACTION_DEVICE_AUTOMATION: self._async_device_automation,
- ACTION_ACTIVATE_SCENE: self._async_activate_scene,
- }
self._referenced_entities: Optional[Set[str]] = None
self._referenced_devices: Optional[Set[str]] = None
+ def _changed(self):
+ if self._change_listener:
+ self._hass.async_add_job(self._change_listener)
+
@property
def is_running(self) -> bool:
"""Return true if script is on."""
- return self._cur != -1
+ return len(self._runs) > 0
@property
def referenced_devices(self):
@@ -173,12 +603,12 @@ class Script:
referenced = set()
for step in self.sequence:
- action = _determine_action(step)
+ action = cv.determine_script_action(step)
- if action == ACTION_CHECK_CONDITION:
+ if action == cv.SCRIPT_ACTION_CHECK_CONDITION:
referenced |= condition.async_extract_devices(step)
- elif action == ACTION_DEVICE_AUTOMATION:
+ elif action == cv.SCRIPT_ACTION_DEVICE_AUTOMATION:
referenced.add(step[CONF_DEVICE_ID])
self._referenced_devices = referenced
@@ -193,9 +623,9 @@ class Script:
referenced = set()
for step in self.sequence:
- action = _determine_action(step)
+ action = cv.determine_script_action(step)
- if action == ACTION_CALL_SERVICE:
+ if action == cv.SCRIPT_ACTION_CALL_SERVICE:
data = step.get(service.CONF_SERVICE_DATA)
if not data:
continue
@@ -211,10 +641,10 @@ class Script:
for entity_id in entity_ids:
referenced.add(entity_id)
- elif action == ACTION_CHECK_CONDITION:
+ elif action == cv.SCRIPT_ACTION_CHECK_CONDITION:
referenced |= condition.async_extract_entities(step)
- elif action == ACTION_ACTIVATE_SCENE:
+ elif action == cv.SCRIPT_ACTION_ACTIVATE_SCENE:
referenced.add(step[CONF_SCENE])
self._referenced_entities = referenced
@@ -223,288 +653,62 @@ class Script:
def run(self, variables=None, context=None):
"""Run script."""
asyncio.run_coroutine_threadsafe(
- self.async_run(variables, context), self.hass.loop
+ self.async_run(variables, context), self._hass.loop
).result()
async def async_run(
self, variables: Optional[Sequence] = None, context: Optional[Context] = None
) -> None:
- """Run script.
-
- This method is a coroutine.
- """
- self.last_triggered = date_util.utcnow()
- if self._cur == -1:
- self._log("Running script")
- self._cur = 0
-
- # Unregister callback if we were in a delay or wait but turn on is
- # called again. In that case we just continue execution.
- self._async_remove_listener()
-
- for cur, action in islice(enumerate(self.sequence), self._cur, None):
- try:
- await self._handle_action(action, variables, context)
- except _SuspendScript:
- # Store next step to take and notify change listeners
- self._cur = cur + 1
- if self._change_listener:
- self.hass.async_add_job(self._change_listener)
+ """Run script."""
+ if self.is_running:
+ if self._if_running == IF_RUNNING_IGNORE:
+ self._log("Skipping script")
return
- except _StopScript:
- break
- except Exception:
- # Store the step that had an exception
- self._exception_step = cur
- # Set script to not running
- self._cur = -1
- self.last_action = None
- # Pass exception on.
- raise
- # Set script to not-running.
- self._cur = -1
- self.last_action = None
- if self._change_listener:
- self.hass.async_add_job(self._change_listener)
+ 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()
- def stop(self) -> None:
- """Stop running script."""
- run_callback_threadsafe(self.hass.loop, self.async_stop).result()
-
- @callback
- def async_stop(self) -> None:
- """Stop running script."""
- if self._cur == -1:
- return
-
- self._cur = -1
- self._async_remove_listener()
- if self._change_listener:
- self.hass.async_add_job(self._change_listener)
-
- @callback
- def async_log_exception(self, logger, message_base, exception):
- """Log an exception for this script.
-
- Should only be called on exceptions raised by this scripts async_run.
- """
- step = self._exception_step
- action = self.sequence[step]
- action_type = _determine_action(action)
-
- error = None
- meth = logger.error
-
- if isinstance(exception, vol.Invalid):
- error_desc = "Invalid data"
-
- elif isinstance(exception, exceptions.TemplateError):
- error_desc = "Error rendering template"
-
- elif isinstance(exception, exceptions.Unauthorized):
- error_desc = "Unauthorized"
-
- elif isinstance(exception, exceptions.ServiceNotFound):
- error_desc = "Service not found"
-
- else:
- # Print the full stack trace, unknown error
- error_desc = "Unknown error"
- meth = logger.exception
- error = ""
-
- if error is None:
- error = str(exception)
-
- meth(
- "%s. %s for %s at pos %s: %s",
- message_base,
- error_desc,
- action_type,
- step + 1,
- error,
- )
-
- async def _handle_action(self, action, variables, context):
- """Handle an action."""
- await self._actions[_determine_action(action)](action, variables, context)
-
- async def _async_delay(self, action, variables, context):
- """Handle delay."""
- # Call ourselves in the future to continue work
- unsub = None
-
- @callback
- def async_script_delay(now):
- """Handle delay."""
- with suppress(ValueError):
- self._async_listener.remove(unsub)
-
- self.hass.async_create_task(self.async_run(variables, context))
-
- delay = action[CONF_DELAY]
-
- try:
- if isinstance(delay, template.Template):
- delay = vol.All(cv.time_period, cv.positive_timedelta)(
- delay.async_render(variables)
- )
- elif isinstance(delay, dict):
- delay_data = {}
- delay_data.update(template.render_complex(delay, variables))
- delay = cv.time_period(delay_data)
- except (exceptions.TemplateError, vol.Invalid) as ex:
- _LOGGER.error("Error rendering '%s' delay template: %s", self.name, ex)
- raise _StopScript
-
- self.last_action = action.get(CONF_ALIAS, f"delay {delay}")
- self._log("Executing step %s" % self.last_action)
-
- unsub = async_track_point_in_utc_time(
- self.hass, async_script_delay, date_util.utcnow() + delay
- )
- self._async_listener.append(unsub)
- raise _SuspendScript
-
- async def _async_wait_template(self, action, variables, context):
- """Handle a wait template."""
- # Call ourselves in the future to continue work
- wait_template = action[CONF_WAIT_TEMPLATE]
- wait_template.hass = self.hass
-
- self.last_action = action.get(CONF_ALIAS, "wait template")
- self._log("Executing step %s" % self.last_action)
-
- # check if condition already okay
- if condition.async_template(self.hass, wait_template, variables):
- return
-
- @callback
- def async_script_wait(entity_id, from_s, to_s):
- """Handle script after template condition is true."""
- self._async_remove_listener()
- self.hass.async_create_task(self.async_run(variables, context))
-
- self._async_listener.append(
- async_track_template(self.hass, wait_template, async_script_wait, variables)
- )
-
- if CONF_TIMEOUT in action:
- self._async_set_timeout(
- action, variables, context, action.get(CONF_CONTINUE, True)
- )
-
- raise _SuspendScript
-
- async def _async_call_service(self, action, variables, context):
- """Call the service specified in the action.
-
- This method is a coroutine.
- """
- self.last_action = action.get(CONF_ALIAS, "call service")
- self._log("Executing step %s" % self.last_action)
- await service.async_call_from_config(
- self.hass,
- action,
- blocking=True,
- variables=variables,
- validate_config=False,
- context=context,
- )
-
- async def _async_device_automation(self, action, variables, context):
- """Perform the device automation specified in the action.
-
- This method is a coroutine.
- """
- self.last_action = action.get(CONF_ALIAS, "device automation")
- self._log("Executing step %s" % self.last_action)
- platform = await device_automation.async_get_device_automation_platform(
- self.hass, action[CONF_DOMAIN], "action"
- )
- await platform.async_call_action_from_config(
- self.hass, action, variables, context
- )
-
- async def _async_activate_scene(self, action, variables, context):
- """Activate the scene specified in the action.
-
- This method is a coroutine.
- """
- self.last_action = action.get(CONF_ALIAS, "activate scene")
- self._log("Executing step %s" % self.last_action)
- await self.hass.services.async_call(
- scene.DOMAIN,
- SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: action[CONF_SCENE]},
- blocking=True,
- context=context,
- )
-
- async def _async_fire_event(self, action, variables, context):
- """Fire an event."""
- self.last_action = action.get(CONF_ALIAS, action[CONF_EVENT])
- self._log("Executing step %s" % self.last_action)
- event_data = dict(action.get(CONF_EVENT_DATA, {}))
- if CONF_EVENT_DATA_TEMPLATE in action:
- try:
- event_data.update(
- template.render_complex(action[CONF_EVENT_DATA_TEMPLATE], variables)
- )
- except exceptions.TemplateError as ex:
- _LOGGER.error("Error rendering event data template: %s", ex)
-
- self.hass.bus.async_fire(action[CONF_EVENT], event_data, context=context)
-
- async def _async_check_condition(self, action, variables, context):
- """Test if condition is matching."""
- config_cache_key = frozenset((k, str(v)) for k, v in action.items())
- config = self._config_cache.get(config_cache_key)
- if not config:
- config = await condition.async_from_config(self.hass, action, False)
- self._config_cache[config_cache_key] = config
-
- self.last_action = action.get(CONF_ALIAS, action[CONF_CONDITION])
- check = config(self.hass, variables)
- self._log(f"Test condition {self.last_action}: {check}")
-
- if not check:
- raise _StopScript
-
- def _async_set_timeout(self, action, variables, context, continue_on_timeout):
- """Schedule a timeout to abort or continue script."""
- timeout = action[CONF_TIMEOUT]
- unsub = None
-
- @callback
- def async_script_timeout(now):
- """Call after timeout is retrieve."""
- with suppress(ValueError):
- self._async_listener.remove(unsub)
-
- # Check if we want to continue to execute
- # the script after the timeout
- if continue_on_timeout:
- self.hass.async_create_task(self.async_run(variables, context))
+ self.last_triggered = utcnow()
+ if self._run_mode == RUN_MODE_LEGACY:
+ if self._runs:
+ shared = cast(Optional[_LegacyScriptRun], self._runs[0])
else:
- self._log("Timeout reached, abort script.")
- self.async_stop()
+ shared = None
+ run: _ScriptRunBase = _LegacyScriptRun(
+ self._hass, self, variables, context, self._log_exceptions, shared
+ )
+ else:
+ if self._run_mode == RUN_MODE_BACKGROUND:
+ run = _BackgroundScriptRun(
+ self._hass, self, variables, context, self._log_exceptions
+ )
+ else:
+ run = _BlockingScriptRun(
+ self._hass, self, variables, context, self._log_exceptions
+ )
+ self._runs.append(run)
+ await run.async_run()
- unsub = async_track_point_in_utc_time(
- self.hass, async_script_timeout, date_util.utcnow() + timeout
- )
- self._async_listener.append(unsub)
+ async def async_stop(self) -> None:
+ """Stop running script."""
+ if not self.is_running:
+ return
+ await asyncio.shield(asyncio.gather(*(run.async_stop() for run in self._runs)))
+ self._changed()
- def _async_remove_listener(self):
- """Remove point in time listener, if any."""
- for unsub in self._async_listener:
- unsub()
- self._async_listener.clear()
+ def _log(self, msg, *args, level=logging.INFO):
+ if self.name:
+ msg = f"{self.name}: {msg}"
+ if level == _LOG_EXCEPTION:
+ self._logger.exception(msg, *args)
+ else:
+ self._logger.log(level, msg, *args)
- def _log(self, msg):
- """Logger helper."""
- if self.name is not None:
- msg = f"Script {self.name}: {msg}"
-
- _LOGGER.info(msg)
+ 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 9085c929651..578d5368314 100644
--- a/homeassistant/helpers/service.py
+++ b/homeassistant/helpers/service.py
@@ -10,6 +10,8 @@ from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL
from homeassistant.const import (
ATTR_AREA_ID,
ATTR_ENTITY_ID,
+ CONF_SERVICE,
+ CONF_SERVICE_TEMPLATE,
ENTITY_MATCH_ALL,
ENTITY_MATCH_NONE,
)
@@ -29,8 +31,6 @@ from homeassistant.util.yaml.loader import JSON_TYPE
# mypy: allow-untyped-defs, no-check-untyped-defs
-CONF_SERVICE = "service"
-CONF_SERVICE_TEMPLATE = "service_template"
CONF_SERVICE_ENTITY_ID = "entity_id"
CONF_SERVICE_DATA = "data"
CONF_SERVICE_DATA_TEMPLATE = "data_template"
diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py
index aed6da37518..1cad8eec473 100644
--- a/homeassistant/helpers/storage.py
+++ b/homeassistant/helpers/storage.py
@@ -210,3 +210,10 @@ class Store:
async def _async_migrate_func(self, old_version, old_data):
"""Migrate to the new version."""
raise NotImplementedError
+
+ async def async_remove(self):
+ """Remove all data."""
+ try:
+ await self.hass.async_add_executor_job(os.unlink, self.path)
+ except FileNotFoundError:
+ pass
diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py
index 7d1d6f2b3e7..26ef3929a3f 100644
--- a/homeassistant/helpers/system_info.py
+++ b/homeassistant/helpers/system_info.py
@@ -23,14 +23,13 @@ async def async_get_system_info(hass: HomeAssistantType) -> Dict:
"arch": platform.machine(),
"timezone": str(hass.config.time_zone),
"os_name": platform.system(),
+ "os_version": platform.release(),
}
if platform.system() == "Windows":
info_object["os_version"] = platform.win32_ver()[0]
elif platform.system() == "Darwin":
info_object["os_version"] = platform.mac_ver()[0]
- elif platform.system() == "FreeBSD":
- info_object["os_version"] = platform.release()
elif platform.system() == "Linux":
info_object["docker"] = os.path.isfile("/.dockerenv")
diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py
index fe877fe9bb8..b2fe87148b1 100644
--- a/homeassistant/helpers/update_coordinator.py
+++ b/homeassistant/helpers/update_coordinator.py
@@ -5,6 +5,8 @@ import logging
from time import monotonic
from typing import Any, Awaitable, Callable, List, Optional
+import aiohttp
+
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util.dt import utcnow
@@ -114,6 +116,16 @@ class DataUpdateCoordinator:
start = monotonic()
self.data = await self.update_method()
+ except asyncio.TimeoutError:
+ if self.last_update_success:
+ self.logger.error("Timeout fetching %s data", self.name)
+ self.last_update_success = False
+
+ except aiohttp.ClientError as err:
+ if self.last_update_success:
+ self.logger.error("Error requesting %s data: %s", self.name, err)
+ self.last_update_success = False
+
except UpdateFailed as err:
if self.last_update_success:
self.logger.error("Error fetching %s data: %s", self.name, err)
diff --git a/homeassistant/loader.py b/homeassistant/loader.py
index 4c46d437760..155dd0e059d 100644
--- a/homeassistant/loader.py
+++ b/homeassistant/loader.py
@@ -45,7 +45,7 @@ CUSTOM_WARNING = (
"You are using a custom integration for %s which has not "
"been tested by Home Assistant. This component might "
"cause stability problems, be sure to disable it if you "
- "do experience issues with Home Assistant."
+ "experience issues with Home Assistant."
)
_UNDEF = object()
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index d8450d873b7..36499440c19 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -7,11 +7,12 @@ async_timeout==3.0.1
attrs==19.3.0
bcrypt==3.1.7
certifi>=2019.11.28
+ciso8601==2.1.3
cryptography==2.8
defusedxml==0.6.0
distro==1.4.0
-hass-nabucasa==0.31
-home-assistant-frontend==20200220.5
+hass-nabucasa==0.32.2
+home-assistant-frontend==20200318.0
importlib-metadata==1.5.0
jinja2>=2.10.3
netdisco==2.6.0
@@ -19,12 +20,12 @@ pip>=8.0.3
python-slugify==4.0.0
pytz>=2019.03
pyyaml==5.3
-requests==2.22.0
+requests==2.23.0
ruamel.yaml==0.15.100
sqlalchemy==1.3.13
voluptuous-serialize==2.3.0
voluptuous==0.11.7
-zeroconf==0.24.4
+zeroconf==0.24.5
pycryptodome>=3.6.6
diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py
index 58125bc4829..2c885dd1713 100644
--- a/homeassistant/scripts/benchmark/__init__.py
+++ b/homeassistant/scripts/benchmark/__init__.py
@@ -8,6 +8,7 @@ from timeit import default_timer as timer
from typing import Callable, Dict
from homeassistant import core
+from homeassistant.components.websocket_api.const import JSON_DUMP
from homeassistant.const import ATTR_NOW, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED
from homeassistant.util import dt as dt_util
@@ -50,8 +51,8 @@ def benchmark(func):
@benchmark
-async def async_million_events(hass):
- """Run a million events."""
+async def fire_events(hass):
+ """Fire a million events."""
count = 0
event_name = "benchmark_event"
event = asyncio.Event()
@@ -78,7 +79,7 @@ async def async_million_events(hass):
@benchmark
-async def async_million_time_changed_helper(hass):
+async def time_changed_helper(hass):
"""Run a million events through time changed helper."""
count = 0
event = asyncio.Event()
@@ -106,7 +107,7 @@ async def async_million_time_changed_helper(hass):
@benchmark
-async def async_million_state_changed_helper(hass):
+async def state_changed_helper(hass):
"""Run a million events through state changed helper."""
count = 0
entity_id = "light.kitchen"
@@ -139,22 +140,19 @@ async def async_million_state_changed_helper(hass):
@benchmark
-@asyncio.coroutine
-def logbook_filtering_state(hass):
+async def logbook_filtering_state(hass):
"""Filter state changes."""
- return _logbook_filtering(hass, 1, 1)
+ return await _logbook_filtering(hass, 1, 1)
@benchmark
-@asyncio.coroutine
-def logbook_filtering_attributes(hass):
+async def logbook_filtering_attributes(hass):
"""Filter attribute changes."""
- return _logbook_filtering(hass, 1, 2)
+ return await _logbook_filtering(hass, 1, 2)
@benchmark
-@asyncio.coroutine
-def _logbook_filtering(hass, last_changed, last_updated):
+async def _logbook_filtering(hass, last_changed, last_updated):
from homeassistant.components import logbook
entity_id = "test.entity"
@@ -177,11 +175,33 @@ def _logbook_filtering(hass, last_changed, last_updated):
# pylint: disable=protected-access
entities_filter = logbook._generate_filter_from_config({})
for _ in range(10 ** 5):
- if logbook._keep_event(event, entities_filter):
+ if logbook._keep_event(hass, event, entities_filter):
yield event
start = timer()
- list(logbook.humanify(None, yield_events(event)))
+ list(logbook.humanify(hass, yield_events(event)))
return timer() - start
+
+
+@benchmark
+async def valid_entity_id(hass):
+ """Run valid entity ID a million times."""
+ start = timer()
+ for _ in range(10 ** 6):
+ core.valid_entity_id("light.kitchen")
+ return timer() - start
+
+
+@benchmark
+async def json_serialize_states(hass):
+ """Serialize million states with websocket default encoder."""
+ states = [
+ core.State("light.kitchen", "on", {"friendly_name": "Kitchen Lights"})
+ for _ in range(10 ** 6)
+ ]
+
+ start = timer()
+ JSON_DUMP(states)
+ return timer() - start
diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py
index f39fa5f1e55..07b6a8d48f8 100644
--- a/homeassistant/util/__init__.py
+++ b/homeassistant/util/__init__.py
@@ -44,9 +44,9 @@ def sanitize_path(path: str) -> str:
return RE_SANITIZE_PATH.sub("", path)
-def slugify(text: str) -> str:
+def slugify(text: str, *, separator: str = "_") -> str:
"""Slugify a given text."""
- return unicode_slug.slugify(text, separator="_") # type: ignore
+ return unicode_slug.slugify(text, separator=separator) # type: ignore
def repr_helper(inp: Any) -> str:
diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py
index dde18688d9f..084888c188c 100644
--- a/homeassistant/util/dt.py
+++ b/homeassistant/util/dt.py
@@ -3,6 +3,7 @@ import datetime as dt
import re
from typing import Any, Dict, List, Optional, Tuple, Union, cast
+import ciso8601
import pytz
import pytz.exceptions as pytzexceptions
import pytz.tzinfo as pytzinfo
@@ -122,6 +123,10 @@ def parse_datetime(dt_str: str) -> Optional[dt.datetime]:
Raises ValueError if the input is well formatted but not a valid datetime.
Returns None if the input isn't well formatted.
"""
+ try:
+ return ciso8601.parse_datetime(dt_str)
+ except (ValueError, IndexError):
+ pass
match = DATETIME_RE.match(dt_str)
if not match:
return None
diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py
index 6b921ade961..ba4d1e77576 100644
--- a/homeassistant/util/yaml/loader.py
+++ b/homeassistant/util/yaml/loader.py
@@ -41,7 +41,6 @@ def clear_secret_cache() -> None:
__SECRET_CACHE.clear()
-# pylint: disable=too-many-ancestors
class SafeLineLoader(yaml.SafeLoader):
"""Loader class that keeps track of line numbers."""
diff --git a/pylintrc b/pylintrc
index fcc38ec0734..125062c8cfe 100644
--- a/pylintrc
+++ b/pylintrc
@@ -5,6 +5,7 @@ ignore=tests
jobs=2
load-plugins=pylint_strict_informational
persistent=no
+extension-pkg-whitelist=ciso8601
[BASIC]
good-names=id,i,j,k,ex,Run,_,fp
diff --git a/requirements_all.txt b/requirements_all.txt
index b0b4bd65479..afe893dbc56 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -5,6 +5,7 @@ async_timeout==3.0.1
attrs==19.3.0
bcrypt==3.1.7
certifi>=2019.11.28
+ciso8601==2.1.3
importlib-metadata==1.5.0
jinja2>=2.10.3
PyJWT==1.7.1
@@ -13,7 +14,7 @@ pip>=8.0.3
python-slugify==4.0.0
pytz>=2019.03
pyyaml==5.3
-requests==2.22.0
+requests==2.23.0
ruamel.yaml==0.15.100
voluptuous==0.11.7
voluptuous-serialize==2.3.0
@@ -105,7 +106,7 @@ WazeRouteCalculator==0.12
YesssSMS==0.4.1
# homeassistant.components.abode
-abodepy==0.17.0
+abodepy==0.18.1
# homeassistant.components.mcp23017
adafruit-blinka==3.9.0
@@ -135,10 +136,10 @@ aio_geojson_nsw_rfs_incidents==0.3
aio_georss_gdacs==0.3
# homeassistant.components.ambient_station
-aioambient==1.0.2
+aioambient==1.0.4
# homeassistant.components.asuswrt
-aioasuswrt==1.1.22
+aioasuswrt==1.2.2
# homeassistant.components.automatic
aioautomatic==0.6.5
@@ -161,6 +162,9 @@ aioftp==0.12.0
# homeassistant.components.harmony
aioharmony==0.1.13
+# homeassistant.components.homekit_controller
+aiohomekit[IP]==0.2.29.1
+
# homeassistant.components.emulated_hue
# homeassistant.components.http
aiohttp_cors==0.7.0
@@ -199,7 +203,7 @@ aiopylgtv==0.3.3
aioswitcher==2019.4.26
# homeassistant.components.unifi
-aiounifi==14
+aiounifi==15
# homeassistant.components.wwlln
aiowwlln==2.0.2
@@ -274,6 +278,9 @@ avea==1.4
# homeassistant.components.avion
# avion==0.10
+# homeassistant.components.avri
+avri-api==0.1.7
+
# homeassistant.components.axis
axis==25
@@ -302,7 +309,7 @@ beautifulsoup4==4.8.2
beewi_smartclim==0.0.7
# homeassistant.components.zha
-bellows-homeassistant==0.13.2
+bellows-homeassistant==0.14.0
# homeassistant.components.bmw_connected_drive
bimmer_connected==0.7.1
@@ -330,20 +337,20 @@ blockchain==1.4.4
# bme680==1.0.5
# homeassistant.components.bom
-bomradarloop==0.1.3
+bomradarloop==0.1.4
# homeassistant.components.amazon_polly
# homeassistant.components.route53
boto3==1.9.252
# homeassistant.components.braviatv
-bravia-tv==1.0
+bravia-tv==1.0.1
# homeassistant.components.broadlink
broadlink==0.12.0
# homeassistant.components.brother
-brother==0.1.6
+brother==0.1.8
# homeassistant.components.brottsplatskartan
brottsplatskartan==0.0.1
@@ -361,7 +368,7 @@ bthomehub5-devicelist==0.1.1
btsmarthub_devicelist==0.1.3
# homeassistant.components.buienradar
-buienradar==1.0.1
+buienradar==1.0.4
# homeassistant.components.caldav
caldav==0.6.1
@@ -429,10 +436,10 @@ defusedxml==0.6.0
deluge-client==1.7.1
# homeassistant.components.denonavr
-denonavr==0.7.12
+denonavr==0.8.0
# homeassistant.components.directv
-directpy==0.6
+directpy==0.7
# homeassistant.components.discogs
discogs_client==2.2.2
@@ -459,10 +466,10 @@ dsmr_parser==0.18
dweepy==0.3.0
# homeassistant.components.dynalite
-dynalite_devices==0.1.22
+dynalite_devices==0.1.32
# homeassistant.components.rainforest_eagle
-eagle200_reader==0.2.1
+eagle200_reader==0.2.4
# homeassistant.components.ebusd
ebusdpy==0.0.16
@@ -492,7 +499,7 @@ enocean==0.50
enturclient==0.2.1
# homeassistant.components.environment_canada
-env_canada==0.0.34
+env_canada==0.0.35
# homeassistant.components.envirophat
# envirophat==0.0.6
@@ -634,6 +641,9 @@ greeneye_monitor==2.0
# homeassistant.components.greenwave
greenwavereality==0.5.1
+# homeassistant.components.griddy
+griddypower==0.1.0
+
# homeassistant.components.growatt_server
growattServer==0.0.1
@@ -656,7 +666,7 @@ habitipy==0.2.0
hangups==0.4.9
# homeassistant.components.cloud
-hass-nabucasa==0.31
+hass-nabucasa==0.32.2
# homeassistant.components.mqtt
hbmqtt==0.9.5
@@ -686,13 +696,10 @@ hole==0.5.0
holidays==0.10.1
# homeassistant.components.frontend
-home-assistant-frontend==20200220.5
+home-assistant-frontend==20200318.0
# homeassistant.components.zwave
-homeassistant-pyozw==0.1.8
-
-# homeassistant.components.homekit_controller
-homekit[IP]==0.15.0
+homeassistant-pyozw==0.1.9
# homeassistant.components.homematicip_cloud
homematicip==0.10.17
@@ -705,7 +712,7 @@ horimote==0.4.1
httplib2==0.10.3
# homeassistant.components.huawei_lte
-huawei-lte-api==1.4.7
+huawei-lte-api==1.4.10
# homeassistant.components.hydrawise
hydrawiser==0.1.1
@@ -715,6 +722,9 @@ hydrawiser==0.1.1
# homeassistant.components.htu21d
# i2csense==0.0.4
+# homeassistant.components.iammeter
+iammeter==0.1.3
+
# homeassistant.components.iaqualink
iaqualink==0.3.1
@@ -737,7 +747,7 @@ incomfort-client==0.4.0
influxdb==5.2.3
# homeassistant.components.insteon
-insteonplm==0.16.7
+insteonplm==0.16.8
# homeassistant.components.iperf3
iperf3==0.1.11
@@ -1078,7 +1088,7 @@ pushover_complete==1.1.1
pwmled==1.5.0
# homeassistant.components.august
-py-august==0.14.0
+py-august==0.25.0
# homeassistant.components.canary
py-canary==0.5.0
@@ -1112,7 +1122,7 @@ pyRFXtrx==0.25
# pySwitchmate==0.4.6
# homeassistant.components.tibber
-pyTibber==0.12.2
+pyTibber==0.13.3
# homeassistant.components.dlink
pyW215==0.6.0
@@ -1148,7 +1158,7 @@ pyalmond==0.0.2
pyarlo==0.2.3
# homeassistant.components.netatmo
-pyatmo==3.2.4
+pyatmo==3.3.0
# homeassistant.components.atome
pyatome==0.1.1
@@ -1178,7 +1188,7 @@ pycfdns==0.0.1
pychannels==1.0.0
# homeassistant.components.cast
-pychromecast==4.1.1
+pychromecast==4.2.0
# homeassistant.components.cmus
pycmus==0.1.1
@@ -1240,6 +1250,9 @@ pyephember==0.3.1
# homeassistant.components.everlights
pyeverlights==0.1.0
+# homeassistant.components.ezviz
+pyezviz==0.1.5
+
# homeassistant.components.fortigate
pyfgt==0.5.1
@@ -1253,7 +1266,7 @@ pyflexit==0.3
pyflic-homeassistant==0.4.dev0
# homeassistant.components.flume
-pyflume==0.2.4
+pyflume==0.3.0
# homeassistant.components.flunearyou
pyflunearyou==1.0.3
@@ -1290,10 +1303,10 @@ pyheos==0.6.0
pyhik==0.2.5
# homeassistant.components.hive
-pyhiveapi==0.2.19.3
+pyhiveapi==0.2.20.1
# homeassistant.components.homematic
-pyhomematic==0.1.64
+pyhomematic==0.1.65
# homeassistant.components.homeworks
pyhomeworks==0.0.6
@@ -1302,13 +1315,13 @@ pyhomeworks==0.0.6
pyialarm==0.3
# homeassistant.components.icloud
-pyicloud==0.9.2
+pyicloud==0.9.5
# homeassistant.components.intesishome
pyintesishome==1.6
# homeassistant.components.ipma
-pyipma==2.0.4
+pyipma==2.0.5
# homeassistant.components.iqvia
pyiqvia==0.2.1
@@ -1362,7 +1375,7 @@ pymailgunner==1.4
pymediaroom==0.6.4
# homeassistant.components.melcloud
-pymelcloud==2.1.0
+pymelcloud==2.4.0
# homeassistant.components.somfy
pymfy==0.7.1
@@ -1465,6 +1478,9 @@ pypoint==1.1.2
# homeassistant.components.ps4
pyps4-2ndscreen==1.0.7
+# homeassistant.components.qvr_pro
+pyqvrpro==0.51
+
# homeassistant.components.qwikswitch
pyqwikswitch==0.93
@@ -1519,6 +1535,9 @@ pysmartthings==0.7.0
# homeassistant.components.smarty
pysmarty==0.8
+# homeassistant.components.edl21
+pysml==0.0.2
+
# homeassistant.components.snmp
pysnmp==4.4.12
@@ -1543,6 +1562,9 @@ pysupla==0.0.3
# homeassistant.components.syncthru
pysyncthru==0.5.0
+# homeassistant.components.tankerkoenig
+pytankerkoenig==0.0.6
+
# homeassistant.components.tautulli
pytautulli==0.5.0
@@ -1562,7 +1584,7 @@ python-clementine-remote==1.0.1
python-digitalocean==1.13.2
# homeassistant.components.ecobee
-python-ecobee-api==0.2.1
+python-ecobee-api==0.2.2
# homeassistant.components.eq3btsmart
# python-eq3bt==0.1.11
@@ -1589,7 +1611,7 @@ python-gitlab==1.6.0
python-hpilo==4.3
# homeassistant.components.izone
-python-izone==1.1.1
+python-izone==1.1.2
# homeassistant.components.joaoapps_join
python-join-api==0.0.4
@@ -1683,7 +1705,7 @@ pytradfri[async]==6.4.0
pytrafikverket==0.1.6.1
# homeassistant.components.ubee
-pyubee==0.8
+pyubee==0.9
# homeassistant.components.uptimerobot
pyuptimerobot==0.0.5
@@ -1701,7 +1723,7 @@ pyversasense==0.0.6
pyvesync==1.1.0
# homeassistant.components.vizio
-pyvizio==0.1.21
+pyvizio==0.1.35
# homeassistant.components.velux
pyvlx==0.2.12
@@ -1722,7 +1744,7 @@ pyzabbix==0.7.4
pyzbar==0.1.7
# homeassistant.components.qnap
-qnapstats==0.2.7
+qnapstats==0.3.0
# homeassistant.components.quantum_gateway
quantum-gateway==0.0.5
@@ -1755,7 +1777,7 @@ restrictedpython==5.0
rfk101py==0.0.1
# homeassistant.components.rflink
-rflink==0.0.51
+rflink==0.0.52
# homeassistant.components.ring
ring_doorbell==0.6.0
@@ -1773,7 +1795,7 @@ rocketchat-API==0.6.1
roku==4.0.0
# homeassistant.components.roomba
-roombapy==1.4.2
+roombapy==1.4.3
# homeassistant.components.rova
rova==0.1.0
@@ -1796,6 +1818,9 @@ saltbox==0.1.3
# homeassistant.components.samsungtv
samsungctl[websocket]==0.7.1
+# homeassistant.components.samsungtv
+samsungtvws[websocket]==1.4.0
+
# homeassistant.components.satel_integra
satel_integra==0.3.4
@@ -1812,7 +1837,7 @@ sendgrid==6.1.1
sense-hat==2.2.0
# homeassistant.components.sense
-sense_energy==0.7.0
+sense_energy==0.7.1
# homeassistant.components.sentry
sentry-sdk==0.13.5
@@ -1971,7 +1996,7 @@ temperusb==1.5.3
# tensorflow==1.13.2
# homeassistant.components.tesla
-teslajsonpy==0.3.0
+teslajsonpy==0.5.1
# homeassistant.components.thermoworks_smoke
thermoworks_smoke==0.1.8
@@ -1992,7 +2017,7 @@ todoist-python==8.0.0
toonapilib==3.2.4
# homeassistant.components.totalconnect
-total_connect_client==0.50
+total_connect_client==0.54.1
# homeassistant.components.tplink_lte
tp-connected==0.0.4
@@ -2124,16 +2149,16 @@ yeelight==0.5.0
yeelightsunflower==0.0.10
# homeassistant.components.media_extractor
-youtube_dl==2020.02.16
+youtube_dl==2020.03.08
# homeassistant.components.zengge
zengge==0.2
# homeassistant.components.zeroconf
-zeroconf==0.24.4
+zeroconf==0.24.5
# homeassistant.components.zha
-zha-quirks==0.0.33
+zha-quirks==0.0.37
# homeassistant.components.zhong_hong
zhong_hong_hvac==1.0.9
@@ -2148,10 +2173,10 @@ zigpy-cc==0.1.0
zigpy-deconz==0.7.0
# homeassistant.components.zha
-zigpy-homeassistant==0.13.2
+zigpy-homeassistant==0.16.0
# homeassistant.components.zha
-zigpy-xbee-homeassistant==0.9.0
+zigpy-xbee-homeassistant==0.10.0
# homeassistant.components.zha
zigpy-zigate==0.5.1
diff --git a/requirements_test.txt b/requirements_test.txt
index db76d1ec46b..6fc7e10a78d 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -7,7 +7,7 @@ asynctest==0.13.0
codecov==2.0.15
mock-open==1.3.1
mypy==0.761
-pre-commit==2.1.0
+pre-commit==2.1.1
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 d657d0ba24e..4234621441f 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -26,7 +26,7 @@ RtmAPI==0.7.2
YesssSMS==0.4.1
# homeassistant.components.abode
-abodepy==0.17.0
+abodepy==0.18.1
# homeassistant.components.androidtv
adb-shell==0.1.1
@@ -47,10 +47,10 @@ aio_geojson_nsw_rfs_incidents==0.3
aio_georss_gdacs==0.3
# homeassistant.components.ambient_station
-aioambient==1.0.2
+aioambient==1.0.4
# homeassistant.components.asuswrt
-aioasuswrt==1.1.22
+aioasuswrt==1.2.2
# homeassistant.components.automatic
aioautomatic==0.6.5
@@ -61,6 +61,9 @@ aiobotocore==0.11.1
# homeassistant.components.esphome
aioesphomeapi==2.6.1
+# homeassistant.components.homekit_controller
+aiohomekit[IP]==0.2.29.1
+
# homeassistant.components.emulated_hue
# homeassistant.components.http
aiohttp_cors==0.7.0
@@ -78,7 +81,7 @@ aiopylgtv==0.3.3
aioswitcher==2019.4.26
# homeassistant.components.unifi
-aiounifi==14
+aiounifi==15
# homeassistant.components.wwlln
aiowwlln==2.0.2
@@ -115,19 +118,19 @@ av==6.1.2
axis==25
# homeassistant.components.zha
-bellows-homeassistant==0.13.2
+bellows-homeassistant==0.14.0
# homeassistant.components.bom
-bomradarloop==0.1.3
+bomradarloop==0.1.4
# homeassistant.components.broadlink
broadlink==0.12.0
# homeassistant.components.brother
-brother==0.1.6
+brother==0.1.8
# homeassistant.components.buienradar
-buienradar==1.0.1
+buienradar==1.0.4
# homeassistant.components.caldav
caldav==0.6.1
@@ -159,10 +162,10 @@ datadog==0.15.0
defusedxml==0.6.0
# homeassistant.components.denonavr
-denonavr==0.7.12
+denonavr==0.8.0
# homeassistant.components.directv
-directpy==0.6
+directpy==0.7
# homeassistant.components.updater
distro==1.4.0
@@ -171,7 +174,7 @@ distro==1.4.0
dsmr_parser==0.18
# homeassistant.components.dynalite
-dynalite_devices==0.1.22
+dynalite_devices==0.1.32
# homeassistant.components.ee_brightbox
eebrightbox==0.0.4
@@ -232,6 +235,9 @@ google-api-python-client==1.6.4
# homeassistant.components.google_pubsub
google-cloud-pubsub==0.39.1
+# homeassistant.components.griddy
+griddypower==0.1.0
+
# homeassistant.components.ffmpeg
ha-ffmpeg==2.0
@@ -239,7 +245,7 @@ ha-ffmpeg==2.0
hangups==0.4.9
# homeassistant.components.cloud
-hass-nabucasa==0.31
+hass-nabucasa==0.32.2
# homeassistant.components.mqtt
hbmqtt==0.9.5
@@ -257,13 +263,10 @@ hole==0.5.0
holidays==0.10.1
# homeassistant.components.frontend
-home-assistant-frontend==20200220.5
+home-assistant-frontend==20200318.0
# homeassistant.components.zwave
-homeassistant-pyozw==0.1.8
-
-# homeassistant.components.homekit_controller
-homekit[IP]==0.15.0
+homeassistant-pyozw==0.1.9
# homeassistant.components.homematicip_cloud
homematicip==0.10.17
@@ -273,7 +276,7 @@ homematicip==0.10.17
httplib2==0.10.3
# homeassistant.components.huawei_lte
-huawei-lte-api==1.4.7
+huawei-lte-api==1.4.10
# homeassistant.components.iaqualink
iaqualink==0.3.1
@@ -394,7 +397,7 @@ pure-python-adb==0.2.2.dev0
pushbullet.py==0.11.0
# homeassistant.components.august
-py-august==0.14.0
+py-august==0.25.0
# homeassistant.components.canary
py-canary==0.5.0
@@ -421,6 +424,9 @@ py_nextbusnext==0.1.4
# homeassistant.components.hisense_aehw4a1
pyaehw4a1==0.3.4
+# homeassistant.components.airvisual
+pyairvisual==3.0.1
+
# homeassistant.components.almond
pyalmond==0.0.2
@@ -428,7 +434,7 @@ pyalmond==0.0.2
pyarlo==0.2.3
# homeassistant.components.netatmo
-pyatmo==3.2.4
+pyatmo==3.3.0
# homeassistant.components.blackbird
pyblackbird==0.5
@@ -437,7 +443,7 @@ pyblackbird==0.5
pybotvac==0.0.17
# homeassistant.components.cast
-pychromecast==4.1.1
+pychromecast==4.2.0
# homeassistant.components.coolmaster
pycoolmasternet==0.0.4
@@ -474,13 +480,13 @@ pyhaversion==3.2.0
pyheos==0.6.0
# homeassistant.components.homematic
-pyhomematic==0.1.64
+pyhomematic==0.1.65
# homeassistant.components.icloud
-pyicloud==0.9.2
+pyicloud==0.9.5
# homeassistant.components.ipma
-pyipma==2.0.4
+pyipma==2.0.5
# homeassistant.components.iqvia
pyiqvia==0.2.1
@@ -498,7 +504,7 @@ pylitejet==0.1
pymailgunner==1.4
# homeassistant.components.melcloud
-pymelcloud==2.1.0
+pymelcloud==2.4.0
# homeassistant.components.somfy
pymfy==0.7.1
@@ -563,13 +569,13 @@ pysonos==0.0.24
pyspcwebgw==0.4.0
# homeassistant.components.ecobee
-python-ecobee-api==0.2.1
+python-ecobee-api==0.2.2
# homeassistant.components.darksky
python-forecastio==1.4.0
# homeassistant.components.izone
-python-izone==1.1.1
+python-izone==1.1.2
# homeassistant.components.xiaomi_miio
python-miio==0.4.8
@@ -599,7 +605,7 @@ pyvera==0.3.7
pyvesync==1.1.0
# homeassistant.components.vizio
-pyvizio==0.1.21
+pyvizio==0.1.35
# homeassistant.components.html5
pywebpush==1.9.2
@@ -611,7 +617,7 @@ regenmaschine==1.5.1
restrictedpython==5.0
# homeassistant.components.rflink
-rflink==0.0.51
+rflink==0.0.52
# homeassistant.components.ring
ring_doorbell==0.6.0
@@ -622,6 +628,12 @@ rxv==0.6.0
# homeassistant.components.samsungtv
samsungctl[websocket]==0.7.1
+# homeassistant.components.samsungtv
+samsungtvws[websocket]==1.4.0
+
+# homeassistant.components.sense
+sense_energy==0.7.1
+
# homeassistant.components.sentry
sentry-sdk==0.13.5
@@ -672,7 +684,7 @@ sunwatcher==0.2.1
tellduslive==0.10.10
# homeassistant.components.tesla
-teslajsonpy==0.3.0
+teslajsonpy==0.5.1
# homeassistant.components.toon
toonapilib==3.2.4
@@ -732,10 +744,10 @@ ya_ma==0.3.8
yahooweather==0.10
# homeassistant.components.zeroconf
-zeroconf==0.24.4
+zeroconf==0.24.5
# homeassistant.components.zha
-zha-quirks==0.0.33
+zha-quirks==0.0.37
# homeassistant.components.zha
zigpy-cc==0.1.0
@@ -744,10 +756,10 @@ zigpy-cc==0.1.0
zigpy-deconz==0.7.0
# homeassistant.components.zha
-zigpy-homeassistant==0.13.2
+zigpy-homeassistant==0.16.0
# homeassistant.components.zha
-zigpy-xbee-homeassistant==0.9.0
+zigpy-xbee-homeassistant==0.10.0
# homeassistant.components.zha
zigpy-zigate==0.5.1
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index c4a94f99b18..243490499c3 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -68,7 +68,7 @@ enum34==1000000000.0.0
pycrypto==1000000000.0.0
"""
-IGNORE_PRE_COMMIT_HOOK_ID = ("check-json",)
+IGNORE_PRE_COMMIT_HOOK_ID = ("check-json", "no-commit-to-branch")
def has_tests(module: str):
@@ -135,7 +135,7 @@ def gather_recursive_requirements(domain, seen=None):
def comment_requirement(req):
"""Comment out requirement. Some don't install on all systems."""
- return any(ign in req for ign in COMMENT_REQUIREMENTS)
+ return any(ign.lower() in req.lower() for ign in COMMENT_REQUIREMENTS)
def gather_modules():
diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py
index 934400533e1..660e8065966 100644
--- a/script/hassfest/dependencies.py
+++ b/script/hassfest/dependencies.py
@@ -156,7 +156,7 @@ def calc_allowed_references(integration: Integration) -> Set[str]:
"""Return a set of allowed references."""
allowed_references = (
ALLOWED_USED_COMPONENTS
- | set(integration.manifest["dependencies"])
+ | set(integration.manifest.get("dependencies", []))
| set(integration.manifest.get("after_dependencies", []))
)
@@ -250,7 +250,7 @@ def validate(integrations: Dict[str, Integration], config):
validate_dependencies(integrations, integration)
# check that all referenced dependencies exist
- for dep in integration.manifest["dependencies"]:
+ for dep in integration.manifest.get("dependencies", []):
if dep not in integrations:
integration.add_error(
"dependencies", f"Dependency {dep} does not exist"
diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py
index 7852953dc92..758279cabf8 100644
--- a/script/hassfest/manifest.py
+++ b/script/hassfest/manifest.py
@@ -52,8 +52,8 @@ MANIFEST_SCHEMA = vol.Schema(
vol.Url(), documentation_url # pylint: disable=no-value-for-parameter
),
vol.Optional("quality_scale"): vol.In(SUPPORTED_QUALITY_SCALES),
- vol.Required("requirements"): [str],
- vol.Required("dependencies"): [str],
+ vol.Optional("requirements"): [str],
+ vol.Optional("dependencies"): [str],
vol.Optional("after_dependencies"): [str],
vol.Required("codeowners"): [str],
vol.Optional("logo"): vol.Url(), # pylint: disable=no-value-for-parameter
diff --git a/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py b/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py
index ec332de13e2..8a543a04af3 100644
--- a/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py
+++ b/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py
@@ -1,4 +1,6 @@
"""Test the NEW_NAME config flow."""
+from asynctest import patch
+
from homeassistant import config_entries, setup
from homeassistant.components.NEW_DOMAIN.const import (
DOMAIN,
@@ -48,6 +50,10 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock):
},
)
- result = await hass.config_entries.flow.async_configure(result["flow_id"])
+ with patch(
+ "homeassistant.components.NEW_DOMAIN.async_setup_entry", return_value=True
+ ) as mock_setup:
+ await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+ assert len(mock_setup.mock_calls) == 1
diff --git a/script/version_bump.py b/script/version_bump.py
index 13dfe499f5e..f3ed5e99c55 100755
--- a/script/version_bump.py
+++ b/script/version_bump.py
@@ -140,7 +140,7 @@ def main():
if not arguments.commit:
return
- subprocess.run(["git", "commit", "-am", f"Bumped version to {bumped}"])
+ subprocess.run(["git", "commit", "-nam", f"Bumped version to {bumped}"])
def test_bump_version():
diff --git a/setup.py b/setup.py
index 7f9155d9a05..7794a177b1f 100755
--- a/setup.py
+++ b/setup.py
@@ -11,11 +11,11 @@ PROJECT_PACKAGE_NAME = "homeassistant"
PROJECT_LICENSE = "Apache License 2.0"
PROJECT_AUTHOR = "The Home Assistant Authors"
PROJECT_COPYRIGHT = " 2013-{}, {}".format(dt.now().year, PROJECT_AUTHOR)
-PROJECT_URL = "https://home-assistant.io/"
+PROJECT_URL = "https://www.home-assistant.io/"
PROJECT_EMAIL = "hello@home-assistant.io"
PROJECT_GITHUB_USERNAME = "home-assistant"
-PROJECT_GITHUB_REPOSITORY = "home-assistant"
+PROJECT_GITHUB_REPOSITORY = "core"
PYPI_URL = "https://pypi.python.org/pypi/{}".format(PROJECT_PACKAGE_NAME)
GITHUB_PATH = "{}/{}".format(PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY)
@@ -38,6 +38,7 @@ REQUIRES = [
"attrs==19.3.0",
"bcrypt==3.1.7",
"certifi>=2019.11.28",
+ "ciso8601==2.1.3",
"importlib-metadata==1.5.0",
"jinja2>=2.10.3",
"PyJWT==1.7.1",
@@ -47,7 +48,7 @@ REQUIRES = [
"python-slugify==4.0.0",
"pytz>=2019.03",
"pyyaml==5.3",
- "requests==2.22.0",
+ "requests==2.23.0",
"ruamel.yaml==0.15.100",
"voluptuous==0.11.7",
"voluptuous-serialize==2.3.0",
diff --git a/tests/common.py b/tests/common.py
index 5a00a2bc7df..8fdcc9b8f86 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -323,11 +323,15 @@ async def async_mock_mqtt_component(hass, config=None):
if config is None:
config = {mqtt.CONF_BROKER: "mock-broker"}
+ async def _async_fire_mqtt_message(topic, payload, qos, retain):
+ async_fire_mqtt_message(hass, topic, payload, qos, retain)
+
with patch("paho.mqtt.client.Client") as mock_client:
mock_client().connect.return_value = 0
mock_client().subscribe.return_value = (0, 0)
mock_client().unsubscribe.return_value = (0, 0)
mock_client().publish.return_value = (0, 0)
+ mock_client().publish.side_effect = _async_fire_mqtt_message
result = await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config})
assert result
@@ -988,6 +992,10 @@ def mock_storage(data=None):
# To ensure that the data can be serialized
data[store.key] = json.loads(json.dumps(data_to_write, cls=store._encoder))
+ async def mock_remove(store):
+ """Remove data."""
+ data.pop(store.key, None)
+
with patch(
"homeassistant.helpers.storage.Store._async_load",
side_effect=mock_async_load,
@@ -996,6 +1004,10 @@ def mock_storage(data=None):
"homeassistant.helpers.storage.Store._write_data",
side_effect=mock_write_data,
autospec=True,
+ ), patch(
+ "homeassistant.helpers.storage.Store.async_remove",
+ side_effect=mock_remove,
+ autospec=True,
):
yield data
diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py
index 903314ab1b7..a0d575deac0 100644
--- a/tests/components/adguard/test_config_flow.py
+++ b/tests/components/adguard/test_config_flow.py
@@ -40,11 +40,9 @@ async def test_show_authenticate_form(hass):
async def test_connection_error(hass, aioclient_mock):
"""Test we show user form on AdGuard Home connection error."""
aioclient_mock.get(
- "{}://{}:{}/control/status".format(
- "https" if FIXTURE_USER_INPUT[CONF_SSL] else "http",
- FIXTURE_USER_INPUT[CONF_HOST],
- FIXTURE_USER_INPUT[CONF_PORT],
- ),
+ f"{'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http'}"
+ f"://{FIXTURE_USER_INPUT[CONF_HOST]}"
+ f":{FIXTURE_USER_INPUT[CONF_PORT]}/control/status",
exc=aiohttp.ClientError,
)
@@ -60,11 +58,9 @@ async def test_connection_error(hass, aioclient_mock):
async def test_full_flow_implementation(hass, aioclient_mock):
"""Test registering an integration and finishing flow works."""
aioclient_mock.get(
- "{}://{}:{}/control/status".format(
- "https" if FIXTURE_USER_INPUT[CONF_SSL] else "http",
- FIXTURE_USER_INPUT[CONF_HOST],
- FIXTURE_USER_INPUT[CONF_PORT],
- ),
+ f"{'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http'}"
+ f"://{FIXTURE_USER_INPUT[CONF_HOST]}"
+ f":{FIXTURE_USER_INPUT[CONF_PORT]}/control/status",
json={"version": "v0.99.0"},
headers={"Content-Type": "application/json"},
)
@@ -244,11 +240,9 @@ async def test_hassio_connection_error(hass, aioclient_mock):
async def test_outdated_adguard_version(hass, aioclient_mock):
"""Test we show abort when connecting with unsupported AdGuard version."""
aioclient_mock.get(
- "{}://{}:{}/control/status".format(
- "https" if FIXTURE_USER_INPUT[CONF_SSL] else "http",
- FIXTURE_USER_INPUT[CONF_HOST],
- FIXTURE_USER_INPUT[CONF_PORT],
- ),
+ f"{'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http'}"
+ f"://{FIXTURE_USER_INPUT[CONF_HOST]}"
+ f":{FIXTURE_USER_INPUT[CONF_PORT]}/control/status",
json={"version": "v0.98.0"},
headers={"Content-Type": "application/json"},
)
diff --git a/tests/components/airvisual/__init__.py b/tests/components/airvisual/__init__.py
new file mode 100644
index 00000000000..4c116d75d0f
--- /dev/null
+++ b/tests/components/airvisual/__init__.py
@@ -0,0 +1 @@
+"""Define tests for the AirVisual component."""
diff --git a/tests/components/airvisual/test_config_flow.py b/tests/components/airvisual/test_config_flow.py
new file mode 100644
index 00000000000..fb32a86a01a
--- /dev/null
+++ b/tests/components/airvisual/test_config_flow.py
@@ -0,0 +1,124 @@
+"""Define tests for the AirVisual config flow."""
+from asynctest import patch
+from pyairvisual.errors import InvalidKeyError
+
+from homeassistant import data_entry_flow
+from homeassistant.components.airvisual import CONF_GEOGRAPHIES, DOMAIN
+from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
+from homeassistant.const import (
+ CONF_API_KEY,
+ CONF_LATITUDE,
+ CONF_LONGITUDE,
+ CONF_SHOW_ON_MAP,
+)
+
+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"}
+
+ MockConfigEntry(domain=DOMAIN, unique_id="abcde12345", data=conf).add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=conf
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+
+async def test_invalid_api_key(hass):
+ """Test that invalid credentials throws an error."""
+ conf = {CONF_API_KEY: "abcde12345"}
+
+ with patch(
+ "pyairvisual.api.API.nearest_city", side_effect=InvalidKeyError,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=conf
+ )
+ assert result["errors"] == {CONF_API_KEY: "invalid_api_key"}
+
+
+async def test_options_flow(hass):
+ """Test config flow options."""
+ conf = {CONF_API_KEY: "abcde12345"}
+
+ 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.airvisual.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_SHOW_ON_MAP: False}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert config_entry.options == {CONF_SHOW_ON_MAP: False}
+
+
+async def test_show_form(hass):
+ """Test that the form is served with no input."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "user"
+
+
+async def test_step_import(hass):
+ """Test that the import step works."""
+ conf = {
+ CONF_API_KEY: "abcde12345",
+ CONF_GEOGRAPHIES: [{CONF_LATITUDE: 51.528308, CONF_LONGITUDE: -0.3817765}],
+ }
+
+ with patch(
+ "homeassistant.components.airvisual.async_setup_entry", return_value=True
+ ), patch("pyairvisual.api.API.nearest_city"):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == "Cloud API (API key: abcd...)"
+ assert result["data"] == {
+ CONF_API_KEY: "abcde12345",
+ CONF_GEOGRAPHIES: [{CONF_LATITUDE: 51.528308, CONF_LONGITUDE: -0.3817765}],
+ }
+
+
+async def test_step_user(hass):
+ """Test that the user step works."""
+ conf = {
+ CONF_API_KEY: "abcde12345",
+ CONF_LATITUDE: 32.87336,
+ CONF_LONGITUDE: -117.22743,
+ }
+
+ with patch(
+ "homeassistant.components.airvisual.async_setup_entry", return_value=True
+ ), patch("pyairvisual.api.API.nearest_city"):
+ 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"] == "Cloud API (API key: abcd...)"
+ assert result["data"] == {
+ CONF_API_KEY: "abcde12345",
+ CONF_GEOGRAPHIES: [{CONF_LATITUDE: 32.87336, CONF_LONGITUDE: -117.22743}],
+ }
diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py
index ec14cefc291..9b890aa4d25 100644
--- a/tests/components/alarm_control_panel/test_device_trigger.py
+++ b/tests/components/alarm_control_panel/test_device_trigger.py
@@ -207,20 +207,18 @@ async def test_if_fires_on_state_change(hass, calls):
hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_TRIGGERED)
await hass.async_block_till_done()
assert len(calls) == 1
- assert calls[0].data[
- "some"
- ] == "triggered - device - {} - pending - triggered - None".format(
- "alarm_control_panel.entity"
+ assert (
+ calls[0].data["some"]
+ == "triggered - device - alarm_control_panel.entity - pending - triggered - None"
)
# Fake that the entity is disarmed.
hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_DISARMED)
await hass.async_block_till_done()
assert len(calls) == 2
- assert calls[1].data[
- "some"
- ] == "disarmed - device - {} - triggered - disarmed - None".format(
- "alarm_control_panel.entity"
+ assert (
+ calls[1].data["some"]
+ == "disarmed - device - alarm_control_panel.entity - triggered - disarmed - None"
)
# Fake that the entity is armed home.
@@ -228,10 +226,9 @@ async def test_if_fires_on_state_change(hass, calls):
hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_HOME)
await hass.async_block_till_done()
assert len(calls) == 3
- assert calls[2].data[
- "some"
- ] == "armed_home - device - {} - pending - armed_home - None".format(
- "alarm_control_panel.entity"
+ assert (
+ calls[2].data["some"]
+ == "armed_home - device - alarm_control_panel.entity - pending - armed_home - None"
)
# Fake that the entity is armed away.
@@ -239,10 +236,9 @@ async def test_if_fires_on_state_change(hass, calls):
hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_AWAY)
await hass.async_block_till_done()
assert len(calls) == 4
- assert calls[3].data[
- "some"
- ] == "armed_away - device - {} - pending - armed_away - None".format(
- "alarm_control_panel.entity"
+ assert (
+ calls[3].data["some"]
+ == "armed_away - device - alarm_control_panel.entity - pending - armed_away - None"
)
# Fake that the entity is armed night.
@@ -250,8 +246,7 @@ async def test_if_fires_on_state_change(hass, calls):
hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_NIGHT)
await hass.async_block_till_done()
assert len(calls) == 5
- assert calls[4].data[
- "some"
- ] == "armed_night - device - {} - pending - armed_night - None".format(
- "alarm_control_panel.entity"
+ assert (
+ calls[4].data["some"]
+ == "armed_night - device - alarm_control_panel.entity - pending - armed_night - None"
)
diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py
index 55a3112c32f..d4de97f3b46 100644
--- a/tests/components/alert/test_init.py
+++ b/tests/components/alert/test_init.py
@@ -60,7 +60,7 @@ TEST_NOACK = [
None,
None,
]
-ENTITY_ID = alert.ENTITY_ID_FORMAT.format(NAME)
+ENTITY_ID = f"{alert.DOMAIN}.{NAME}"
def turn_on(hass, entity_id):
diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py
old mode 100644
new mode 100755
index f8f4f5f4697..678a8e74027
--- a/tests/components/alexa/test_capabilities.py
+++ b/tests/components/alexa/test_capabilities.py
@@ -8,6 +8,8 @@ from homeassistant.components.media_player.const import (
SUPPORT_PAUSE,
SUPPORT_PLAY,
SUPPORT_STOP,
+ SUPPORT_VOLUME_MUTE,
+ SUPPORT_VOLUME_SET,
)
from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
@@ -684,6 +686,36 @@ async def test_report_playback_state(hass):
)
+async def test_report_speaker_volume(hass):
+ """Test Speaker reports volume correctly."""
+ hass.states.async_set(
+ "media_player.test_speaker",
+ "on",
+ {
+ "friendly_name": "Test media player speaker",
+ "supported_features": SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET,
+ "volume_level": None,
+ "device_class": "speaker",
+ },
+ )
+ properties = await reported_properties(hass, "media_player.test_speaker")
+ properties.assert_not_has_property("Alexa.Speaker", "volume")
+
+ for good_value in range(101):
+ hass.states.async_set(
+ "media_player.test_speaker",
+ "on",
+ {
+ "friendly_name": "Test media player speaker",
+ "supported_features": SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET,
+ "volume_level": good_value / 100,
+ "device_class": "speaker",
+ },
+ )
+ properties = await reported_properties(hass, "media_player.test_speaker")
+ properties.assert_equal("Alexa.Speaker", "volume", good_value)
+
+
async def test_report_image_processing(hass):
"""Test EventDetectionSensor implements humanPresenceDetectionState property."""
hass.states.async_set(
diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py
index d3fe28d227d..d459ee2cc32 100644
--- a/tests/components/alexa/test_flash_briefings.py
+++ b/tests/components/alexa/test_flash_briefings.py
@@ -63,7 +63,7 @@ def alexa_client(loop, hass, hass_client):
def _flash_briefing_req(client, briefing_id):
- return client.get("/api/alexa/flash_briefings/{}".format(briefing_id))
+ return client.get(f"/api/alexa/flash_briefings/{briefing_id}")
async def test_flash_briefing_invalid_id(alexa_client):
diff --git a/tests/components/alexa/test_init.py b/tests/components/alexa/test_init.py
new file mode 100644
index 00000000000..212b48cb436
--- /dev/null
+++ b/tests/components/alexa/test_init.py
@@ -0,0 +1,63 @@
+"""Tests for alexa."""
+from homeassistant.components import logbook
+from homeassistant.components.alexa.const import EVENT_ALEXA_SMART_HOME
+import homeassistant.core as ha
+from homeassistant.setup import async_setup_component
+
+
+async def test_humanify_alexa_event(hass):
+ """Test humanifying Alexa event."""
+ await async_setup_component(hass, "alexa", {})
+ hass.states.async_set("light.kitchen", "on", {"friendly_name": "Kitchen Light"})
+
+ results = list(
+ logbook.humanify(
+ hass,
+ [
+ ha.Event(
+ EVENT_ALEXA_SMART_HOME,
+ {"request": {"namespace": "Alexa.Discovery", "name": "Discover"}},
+ ),
+ ha.Event(
+ EVENT_ALEXA_SMART_HOME,
+ {
+ "request": {
+ "namespace": "Alexa.PowerController",
+ "name": "TurnOn",
+ "entity_id": "light.kitchen",
+ }
+ },
+ ),
+ ha.Event(
+ EVENT_ALEXA_SMART_HOME,
+ {
+ "request": {
+ "namespace": "Alexa.PowerController",
+ "name": "TurnOn",
+ "entity_id": "light.non_existing",
+ }
+ },
+ ),
+ ],
+ )
+ )
+
+ event1, event2, event3 = results
+
+ assert event1["name"] == "Amazon Alexa"
+ assert event1["message"] == "send command Alexa.Discovery/Discover"
+ assert event1["entity_id"] is None
+
+ assert event2["name"] == "Amazon Alexa"
+ assert (
+ event2["message"]
+ == "send command Alexa.PowerController/TurnOn for Kitchen Light"
+ )
+ assert event2["entity_id"] == "light.kitchen"
+
+ assert event3["name"] == "Amazon Alexa"
+ assert (
+ event3["message"]
+ == "send command Alexa.PowerController/TurnOn for light.non_existing"
+ )
+ assert event3["entity_id"] == "light.non_existing"
diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py
index 962ba677403..8937a7938ac 100644
--- a/tests/components/alexa/test_intent.py
+++ b/tests/components/alexa/test_intent.py
@@ -37,7 +37,8 @@ def alexa_client(loop, hass, hass_client):
alexa.DOMAIN,
{
# Key is here to verify we allow other keys in config too
- "homeassistant": {}
+ "homeassistant": {},
+ "alexa": {},
},
)
)
diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py
index a714b69461c..fa8f7fbdc9a 100644
--- a/tests/components/alexa/test_smart_home.py
+++ b/tests/components/alexa/test_smart_home.py
@@ -2592,33 +2592,25 @@ async def test_mode_unsupported_domain(hass):
assert msg["payload"]["type"] == "INVALID_DIRECTIVE"
-async def test_cover(hass):
- """Test garage cover discovery and powerController."""
+async def test_cover_garage_door(hass):
+ """Test garage door cover discovery."""
device = (
- "cover.test",
+ "cover.test_garage_door",
"off",
{
- "friendly_name": "Test cover",
+ "friendly_name": "Test cover garage door",
"supported_features": 3,
"device_class": "garage",
},
)
appliance = await discovery_test(device, hass)
- assert appliance["endpointId"] == "cover#test"
+ assert appliance["endpointId"] == "cover#test_garage_door"
assert appliance["displayCategories"][0] == "GARAGE_DOOR"
- assert appliance["friendlyName"] == "Test cover"
+ assert appliance["friendlyName"] == "Test cover garage door"
assert_endpoint_capabilities(
- appliance,
- "Alexa.ModeController",
- "Alexa.PowerController",
- "Alexa.EndpointHealth",
- "Alexa",
- )
-
- await assert_power_controller_works(
- "cover#test", "cover.open_cover", "cover.close_cover", hass
+ appliance, "Alexa.ModeController", "Alexa.EndpointHealth", "Alexa"
)
@@ -3356,7 +3348,7 @@ async def test_timer_hold(hass):
assert appliance["friendlyName"] == "Laundry"
capabilities = assert_endpoint_capabilities(
- appliance, "Alexa", "Alexa.TimeHoldController"
+ appliance, "Alexa", "Alexa.TimeHoldController", "Alexa.PowerController"
)
time_hold_capability = get_capability(capabilities, "Alexa.TimeHoldController")
@@ -3378,11 +3370,48 @@ async def test_timer_resume(hass):
)
await discovery_test(device, hass)
+ properties = await reported_properties(hass, "timer#laundry")
+ properties.assert_equal("Alexa.PowerController", "powerState", "ON")
+
await assert_request_calls_service(
"Alexa.TimeHoldController", "Resume", "timer#laundry", "timer.start", hass
)
+async def test_timer_start(hass):
+ """Test timer start."""
+ device = (
+ "timer.laundry",
+ "idle",
+ {"friendly_name": "Laundry", "duration": "00:01:00", "remaining": "00:50:00"},
+ )
+ await discovery_test(device, hass)
+
+ properties = await reported_properties(hass, "timer#laundry")
+ properties.assert_equal("Alexa.PowerController", "powerState", "OFF")
+
+ await assert_request_calls_service(
+ "Alexa.PowerController", "TurnOn", "timer#laundry", "timer.start", hass
+ )
+
+
+async def test_timer_cancel(hass):
+ """Test timer cancel."""
+ device = (
+ "timer.laundry",
+ "active",
+ {"friendly_name": "Laundry", "duration": "00:01:00", "remaining": "00:50:00"},
+ )
+ await discovery_test(device, hass)
+
+ properties = await reported_properties(hass, "timer#laundry")
+ properties.assert_equal("Alexa.PowerController", "powerState", "ON")
+
+ await assert_request_calls_service(
+ "Alexa.PowerController", "TurnOff", "timer#laundry", "timer.cancel", hass
+ )
+
+
async def test_vacuum_discovery(hass):
"""Test vacuum discovery."""
device = (
@@ -3394,6 +3423,7 @@ async def test_vacuum_discovery(hass):
| vacuum.SUPPORT_TURN_OFF
| vacuum.SUPPORT_START
| vacuum.SUPPORT_STOP
+ | vacuum.SUPPORT_RETURN_HOME
| vacuum.SUPPORT_PAUSE,
},
)
@@ -3411,6 +3441,17 @@ async def test_vacuum_discovery(hass):
"Alexa",
)
+ properties = await reported_properties(hass, "vacuum#test_1")
+ properties.assert_equal("Alexa.PowerController", "powerState", "OFF")
+
+ await assert_request_calls_service(
+ "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,
+ )
+
async def test_vacuum_fan_speed(hass):
"""Test vacuum fan speed with rangeController."""
@@ -3605,3 +3646,93 @@ async def test_vacuum_resume(hass):
"vacuum.start_pause",
hass,
)
+
+
+async def test_vacuum_discovery_no_turn_on(hass):
+ """Test vacuum discovery for vacuums without turn_on."""
+ device = (
+ "vacuum.test_5",
+ "cleaning",
+ {
+ "friendly_name": "Test vacuum 5",
+ "supported_features": vacuum.SUPPORT_TURN_OFF
+ | vacuum.SUPPORT_START
+ | vacuum.SUPPORT_RETURN_HOME,
+ },
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert_endpoint_capabilities(
+ 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,
+ )
+
+ await assert_request_calls_service(
+ "Alexa.PowerController", "TurnOff", "vacuum#test_5", "vacuum.turn_off", hass,
+ )
+
+
+async def test_vacuum_discovery_no_turn_off(hass):
+ """Test vacuum discovery for vacuums without turn_off."""
+ device = (
+ "vacuum.test_6",
+ "cleaning",
+ {
+ "friendly_name": "Test vacuum 6",
+ "supported_features": vacuum.SUPPORT_TURN_ON
+ | vacuum.SUPPORT_START
+ | vacuum.SUPPORT_RETURN_HOME,
+ },
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert_endpoint_capabilities(
+ appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa",
+ )
+
+ await assert_request_calls_service(
+ "Alexa.PowerController", "TurnOn", "vacuum#test_6", "vacuum.turn_on", hass,
+ )
+
+ await assert_request_calls_service(
+ "Alexa.PowerController",
+ "TurnOff",
+ "vacuum#test_6",
+ "vacuum.return_to_base",
+ hass,
+ )
+
+
+async def test_vacuum_discovery_no_turn_on_or_off(hass):
+ """Test vacuum discovery vacuums without on or off."""
+ device = (
+ "vacuum.test_7",
+ "cleaning",
+ {
+ "friendly_name": "Test vacuum 7",
+ "supported_features": vacuum.SUPPORT_START | vacuum.SUPPORT_RETURN_HOME,
+ },
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert_endpoint_capabilities(
+ appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa",
+ )
+
+ await assert_request_calls_service(
+ "Alexa.PowerController", "TurnOn", "vacuum#test_7", "vacuum.start", hass,
+ )
+
+ await assert_request_calls_service(
+ "Alexa.PowerController",
+ "TurnOff",
+ "vacuum#test_7",
+ "vacuum.return_to_base",
+ hass,
+ )
diff --git a/tests/components/almond/test_config_flow.py b/tests/components/almond/test_config_flow.py
index 0b402ed407d..0b4869ee2a6 100644
--- a/tests/components/almond/test_config_flow.py
+++ b/tests/components/almond/test_config_flow.py
@@ -1,6 +1,7 @@
"""Test the Almond config flow."""
import asyncio
-from unittest.mock import patch
+
+from asynctest import patch
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.almond import config_flow
@@ -55,7 +56,12 @@ async def test_hassio(hass):
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "hassio_confirm"
- result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
+ with patch(
+ "homeassistant.components.almond.async_setup_entry", return_value=True
+ ) as mock_setup:
+ result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
+
+ assert len(mock_setup.mock_calls) == 1
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -128,7 +134,12 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock):
},
)
- result = await hass.config_entries.flow.async_configure(result["flow_id"])
+ with patch(
+ "homeassistant.components.almond.async_setup_entry", return_value=True
+ ) as mock_setup:
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+
+ assert len(mock_setup.mock_calls) == 1
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
entry = hass.config_entries.async_entries(DOMAIN)[0]
diff --git a/tests/components/ambient_station/test_config_flow.py b/tests/components/ambient_station/test_config_flow.py
index 25e46090009..a64b7761338 100644
--- a/tests/components/ambient_station/test_config_flow.py
+++ b/tests/components/ambient_station/test_config_flow.py
@@ -7,6 +7,7 @@ import pytest
from homeassistant import data_entry_flow
from homeassistant.components.ambient_station import CONF_APP_KEY, DOMAIN, config_flow
+from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_API_KEY
from tests.common import MockConfigEntry, load_fixture, mock_coro
@@ -30,12 +31,16 @@ async def test_duplicate_error(hass):
"""Test that errors are shown when duplicates are added."""
conf = {CONF_API_KEY: "12345abcde12345abcde", CONF_APP_KEY: "67890fghij67890fghij"}
- MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
- flow = config_flow.AmbientStationFlowHandler()
- flow.hass = hass
+ MockConfigEntry(
+ domain=DOMAIN, unique_id="67890fghij67890fghij", data=conf
+ ).add_to_hass(hass)
- result = await flow.async_step_user(user_input=conf)
- assert result["errors"] == {CONF_APP_KEY: "identifier_exists"}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=conf
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
@@ -47,6 +52,7 @@ async def test_invalid_api_key(hass, mock_aioambient):
flow = config_flow.AmbientStationFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=conf)
assert result["errors"] == {"base": "invalid_key"}
@@ -59,6 +65,7 @@ async def test_no_devices(hass, mock_aioambient):
flow = config_flow.AmbientStationFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=conf)
assert result["errors"] == {"base": "no_devices"}
@@ -68,6 +75,7 @@ async def test_show_form(hass):
"""Test that the form is served with no input."""
flow = config_flow.AmbientStationFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=None)
@@ -85,6 +93,7 @@ async def test_step_import(hass, mock_aioambient):
flow = config_flow.AmbientStationFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_import(import_config=conf)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -105,6 +114,7 @@ async def test_step_user(hass, mock_aioambient):
flow = config_flow.AmbientStationFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=conf)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py
index 01c8b27bfcc..4417f3d4f91 100644
--- a/tests/components/api/test_init.py
+++ b/tests/components/api/test_init.py
@@ -36,7 +36,7 @@ async def test_api_list_state_entities(hass, mock_api_client):
async def test_api_get_state(hass, mock_api_client):
"""Test if the debug interface allows us to get a state."""
hass.states.async_set("hello.world", "nice", {"attr": 1})
- resp = await mock_api_client.get(const.URL_API_STATES_ENTITY.format("hello.world"))
+ resp = await mock_api_client.get("/api/states/hello.world")
assert resp.status == 200
json = await resp.json()
@@ -51,9 +51,7 @@ async def test_api_get_state(hass, mock_api_client):
async def test_api_get_non_existing_state(hass, mock_api_client):
"""Test if the debug interface allows us to get a state."""
- resp = await mock_api_client.get(
- const.URL_API_STATES_ENTITY.format("does_not_exist")
- )
+ resp = await mock_api_client.get("/api/states/does_not_exist")
assert resp.status == 404
@@ -62,8 +60,7 @@ async def test_api_state_change(hass, mock_api_client):
hass.states.async_set("test.test", "not_to_be_set")
await mock_api_client.post(
- const.URL_API_STATES_ENTITY.format("test.test"),
- json={"state": "debug_state_change2"},
+ "/api/states/test.test", json={"state": "debug_state_change2"}
)
assert hass.states.get("test.test").state == "debug_state_change2"
@@ -75,8 +72,7 @@ async def test_api_state_change_of_non_existing_entity(hass, mock_api_client):
new_state = "debug_state_change"
resp = await mock_api_client.post(
- const.URL_API_STATES_ENTITY.format("test_entity.that_does_not_exist"),
- json={"state": new_state},
+ "/api/states/test_entity.that_does_not_exist", json={"state": new_state}
)
assert resp.status == 201
@@ -88,7 +84,7 @@ async def test_api_state_change_of_non_existing_entity(hass, mock_api_client):
async def test_api_state_change_with_bad_data(hass, mock_api_client):
"""Test if API sends appropriate error if we omit state."""
resp = await mock_api_client.post(
- const.URL_API_STATES_ENTITY.format("test_entity.that_does_not_exist"), json={}
+ "/api/states/test_entity.that_does_not_exist", json={}
)
assert resp.status == 400
@@ -98,15 +94,13 @@ async def test_api_state_change_with_bad_data(hass, mock_api_client):
async def test_api_state_change_to_zero_value(hass, mock_api_client):
"""Test if changing a state to a zero value is possible."""
resp = await mock_api_client.post(
- const.URL_API_STATES_ENTITY.format("test_entity.with_zero_state"),
- json={"state": 0},
+ "/api/states/test_entity.with_zero_state", json={"state": 0}
)
assert resp.status == 201
resp = await mock_api_client.post(
- const.URL_API_STATES_ENTITY.format("test_entity.with_zero_state"),
- json={"state": 0.0},
+ "/api/states/test_entity.with_zero_state", json={"state": 0.0}
)
assert resp.status == 200
@@ -126,15 +120,12 @@ async def test_api_state_change_push(hass, mock_api_client):
hass.bus.async_listen(const.EVENT_STATE_CHANGED, event_listener)
- await mock_api_client.post(
- const.URL_API_STATES_ENTITY.format("test.test"), json={"state": "not_to_be_set"}
- )
+ await mock_api_client.post("/api/states/test.test", json={"state": "not_to_be_set"})
await hass.async_block_till_done()
assert len(events) == 0
await mock_api_client.post(
- const.URL_API_STATES_ENTITY.format("test.test"),
- json={"state": "not_to_be_set", "force_update": True},
+ "/api/states/test.test", json={"state": "not_to_be_set", "force_update": True}
)
await hass.async_block_till_done()
assert len(events) == 1
@@ -152,7 +143,7 @@ async def test_api_fire_event_with_no_data(hass, mock_api_client):
hass.bus.async_listen_once("test.event_no_data", listener)
- await mock_api_client.post(const.URL_API_EVENTS_EVENT.format("test.event_no_data"))
+ await mock_api_client.post("/api/events/test.event_no_data")
await hass.async_block_till_done()
assert len(test_value) == 1
@@ -174,9 +165,7 @@ async def test_api_fire_event_with_data(hass, mock_api_client):
hass.bus.async_listen_once("test_event_with_data", listener)
- await mock_api_client.post(
- const.URL_API_EVENTS_EVENT.format("test_event_with_data"), json={"test": 1}
- )
+ await mock_api_client.post("/api/events/test_event_with_data", json={"test": 1})
await hass.async_block_till_done()
@@ -196,8 +185,7 @@ async def test_api_fire_event_with_invalid_json(hass, mock_api_client):
hass.bus.async_listen_once("test_event_bad_data", listener)
resp = await mock_api_client.post(
- const.URL_API_EVENTS_EVENT.format("test_event_bad_data"),
- data=json.dumps("not an object"),
+ "/api/events/test_event_bad_data", data=json.dumps("not an object")
)
await hass.async_block_till_done()
@@ -207,8 +195,7 @@ async def test_api_fire_event_with_invalid_json(hass, mock_api_client):
# Try now with valid but unusable JSON
resp = await mock_api_client.post(
- const.URL_API_EVENTS_EVENT.format("test_event_bad_data"),
- data=json.dumps([1, 2, 3]),
+ "/api/events/test_event_bad_data", data=json.dumps([1, 2, 3])
)
await hass.async_block_till_done()
@@ -272,9 +259,7 @@ async def test_api_call_service_no_data(hass, mock_api_client):
hass.services.async_register("test_domain", "test_service", listener)
- await mock_api_client.post(
- const.URL_API_SERVICES_SERVICE.format("test_domain", "test_service")
- )
+ await mock_api_client.post("/api/services/test_domain/test_service")
await hass.async_block_till_done()
assert len(test_value) == 1
@@ -295,8 +280,7 @@ async def test_api_call_service_with_data(hass, mock_api_client):
hass.services.async_register("test_domain", "test_service", listener)
await mock_api_client.post(
- const.URL_API_SERVICES_SERVICE.format("test_domain", "test_service"),
- json={"test": 1},
+ "/api/services/test_domain/test_service", json={"test": 1}
)
await hass.async_block_till_done()
@@ -348,7 +332,7 @@ async def test_stream_with_restricted(hass, mock_api_client):
listen_count = _listen_count(hass)
resp = await mock_api_client.get(
- "{}?restrict=test_event1,test_event3".format(const.URL_API_STREAM)
+ f"{const.URL_API_STREAM}?restrict=test_event1,test_event3"
)
assert resp.status == 200
assert listen_count + 1 == _listen_count(hass)
@@ -403,7 +387,7 @@ async def test_api_error_log(hass, aiohttp_client, hass_access_token, hass_admin
) as mock_file:
resp = await client.get(
const.URL_API_ERROR_LOG,
- headers={"Authorization": "Bearer {}".format(hass_access_token)},
+ headers={"Authorization": f"Bearer {hass_access_token}"},
)
assert len(mock_file.mock_calls) == 1
@@ -415,7 +399,7 @@ async def test_api_error_log(hass, aiohttp_client, hass_access_token, hass_admin
hass_admin_user.groups = []
resp = await client.get(
const.URL_API_ERROR_LOG,
- headers={"Authorization": "Bearer {}".format(hass_access_token)},
+ headers={"Authorization": f"Bearer {hass_access_token}"},
)
assert resp.status == 401
@@ -432,8 +416,8 @@ async def test_api_fire_event_context(hass, mock_api_client, hass_access_token):
hass.bus.async_listen("test.event", listener)
await mock_api_client.post(
- const.URL_API_EVENTS_EVENT.format("test.event"),
- headers={"authorization": "Bearer {}".format(hass_access_token)},
+ "/api/events/test.event",
+ headers={"authorization": f"Bearer {hass_access_token}"},
)
await hass.async_block_till_done()
@@ -449,7 +433,7 @@ async def test_api_call_service_context(hass, mock_api_client, hass_access_token
await mock_api_client.post(
"/api/services/test_domain/test_service",
- headers={"authorization": "Bearer {}".format(hass_access_token)},
+ headers={"authorization": f"Bearer {hass_access_token}"},
)
await hass.async_block_till_done()
@@ -464,7 +448,7 @@ async def test_api_set_state_context(hass, mock_api_client, hass_access_token):
await mock_api_client.post(
"/api/states/light.kitchen",
json={"state": "on"},
- headers={"authorization": "Bearer {}".format(hass_access_token)},
+ headers={"authorization": f"Bearer {hass_access_token}"},
)
refresh_token = await hass.auth.async_validate_access_token(hass_access_token)
@@ -542,9 +526,7 @@ async def test_rendering_template_legacy_user(
async def test_api_call_service_not_found(hass, mock_api_client):
"""Test if the API fails 400 if unknown service."""
- resp = await mock_api_client.post(
- const.URL_API_SERVICES_SERVICE.format("test_domain", "test_service")
- )
+ resp = await mock_api_client.post("/api/services/test_domain/test_service")
assert resp.status == 400
@@ -562,7 +544,6 @@ async def test_api_call_service_bad_data(hass, mock_api_client):
)
resp = await mock_api_client.post(
- const.URL_API_SERVICES_SERVICE.format("test_domain", "test_service"),
- json={"hello": 5},
+ "/api/services/test_domain/test_service", json={"hello": 5}
)
assert resp.status == 400
diff --git a/tests/components/arlo/test_sensor.py b/tests/components/arlo/test_sensor.py
index ac64ad8f272..15b85959ff0 100644
--- a/tests/components/arlo/test_sensor.py
+++ b/tests/components/arlo/test_sensor.py
@@ -9,6 +9,7 @@ from homeassistant.const import (
ATTR_ATTRIBUTION,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
+ UNIT_PERCENTAGE,
)
@@ -168,7 +169,7 @@ def test_sensor_icon(temperature_sensor):
def test_unit_of_measure(default_sensor, battery_sensor):
"""Test the unit_of_measurement property."""
assert default_sensor.unit_of_measurement is None
- assert battery_sensor.unit_of_measurement == "%"
+ assert battery_sensor.unit_of_measurement == UNIT_PERCENTAGE
def test_device_class(default_sensor, temperature_sensor, humidity_sensor):
diff --git a/tests/components/asuswrt/test_device_tracker.py b/tests/components/asuswrt/test_device_tracker.py
index 2ecab9c1d37..095b7b76d60 100644
--- a/tests/components/asuswrt/test_device_tracker.py
+++ b/tests/components/asuswrt/test_device_tracker.py
@@ -2,30 +2,16 @@
from unittest.mock import patch
from homeassistant.components.asuswrt import (
- CONF_MODE,
- CONF_PORT,
- CONF_PROTOCOL,
+ CONF_DNSMASQ,
+ CONF_INTERFACE,
DATA_ASUSWRT,
DOMAIN,
)
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.setup import async_setup_component
from tests.common import mock_coro_func
-FAKEFILE = None
-
-VALID_CONFIG_ROUTER_SSH = {
- DOMAIN: {
- CONF_PLATFORM: "asuswrt",
- CONF_HOST: "fake_host",
- CONF_USERNAME: "fake_user",
- CONF_PROTOCOL: "ssh",
- CONF_MODE: "router",
- CONF_PORT: "22",
- }
-}
-
async def test_password_or_pub_key_required(hass):
"""Test creating an AsusWRT scanner without a pass or pubkey."""
@@ -33,7 +19,9 @@ async def test_password_or_pub_key_required(hass):
AsusWrt().connection.async_connect = mock_coro_func()
AsusWrt().is_connected = False
result = await async_setup_component(
- hass, DOMAIN, {DOMAIN: {CONF_HOST: "fake_host", CONF_USERNAME: "fake_user"}}
+ hass,
+ DOMAIN,
+ {DOMAIN: {CONF_HOST: "fake_host", CONF_USERNAME: "fake_user"}},
)
assert not result
@@ -53,8 +41,74 @@ async def test_get_scanner_with_password_no_pubkey(hass):
CONF_HOST: "fake_host",
CONF_USERNAME: "fake_user",
CONF_PASSWORD: "4321",
+ CONF_DNSMASQ: "/",
}
},
)
assert result
assert hass.data[DATA_ASUSWRT] is not None
+
+
+async def test_specify_non_directory_path_for_dnsmasq(hass):
+ """Test creating an AsusWRT scanner with a dnsmasq location which is not a valid directory."""
+ with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt:
+ AsusWrt().connection.async_connect = mock_coro_func()
+ AsusWrt().is_connected = False
+ result = await async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {
+ CONF_HOST: "fake_host",
+ CONF_USERNAME: "fake_user",
+ CONF_PASSWORD: "4321",
+ CONF_DNSMASQ: "?non_directory?",
+ }
+ },
+ )
+ assert not result
+
+
+async def test_interface(hass):
+ """Test creating an AsusWRT scanner using interface eth1."""
+ with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt:
+ AsusWrt().connection.async_connect = mock_coro_func()
+ AsusWrt().connection.async_get_connected_devices = mock_coro_func(
+ return_value={}
+ )
+ result = await async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {
+ CONF_HOST: "fake_host",
+ CONF_USERNAME: "fake_user",
+ CONF_PASSWORD: "4321",
+ CONF_DNSMASQ: "/",
+ CONF_INTERFACE: "eth1",
+ }
+ },
+ )
+ assert result
+ assert hass.data[DATA_ASUSWRT] is not None
+
+
+async def test_no_interface(hass):
+ """Test creating an AsusWRT scanner using no interface."""
+ with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt:
+ AsusWrt().connection.async_connect = mock_coro_func()
+ AsusWrt().is_connected = False
+ result = await async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {
+ CONF_HOST: "fake_host",
+ CONF_USERNAME: "fake_user",
+ CONF_PASSWORD: "4321",
+ CONF_DNSMASQ: "/",
+ CONF_INTERFACE: None,
+ }
+ },
+ )
+ assert not result
diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py
new file mode 100644
index 00000000000..39443c3fef8
--- /dev/null
+++ b/tests/components/asuswrt/test_sensor.py
@@ -0,0 +1,42 @@
+"""The tests for the ASUSWRT sensor platform."""
+from unittest.mock import patch
+
+# import homeassistant.components.sensor as sensor
+from homeassistant.components.asuswrt import (
+ CONF_DNSMASQ,
+ CONF_INTERFACE,
+ CONF_MODE,
+ CONF_PORT,
+ CONF_PROTOCOL,
+ CONF_SENSORS,
+ DATA_ASUSWRT,
+ DOMAIN,
+)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.setup import async_setup_component
+
+from tests.common import mock_coro_func
+
+VALID_CONFIG_ROUTER_SSH = {
+ DOMAIN: {
+ CONF_DNSMASQ: "/",
+ CONF_HOST: "fake_host",
+ CONF_INTERFACE: "eth0",
+ CONF_MODE: "router",
+ CONF_PORT: "22",
+ CONF_PROTOCOL: "ssh",
+ CONF_USERNAME: "fake_user",
+ CONF_PASSWORD: "fake_pass",
+ CONF_SENSORS: "upload",
+ }
+}
+
+
+async def test_default_sensor_setup(hass):
+ """Test creating an AsusWRT sensor."""
+ with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt:
+ AsusWrt().connection.async_connect = mock_coro_func()
+
+ result = await async_setup_component(hass, DOMAIN, VALID_CONFIG_ROUTER_SSH)
+ assert result
+ assert hass.data[DATA_ASUSWRT] is not None
diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py
index 9be8f697b8b..651a17ac80f 100644
--- a/tests/components/august/mocks.py
+++ b/tests/components/august/mocks.py
@@ -1,148 +1,196 @@
"""Mocks for the august component."""
-import datetime
-from unittest.mock import MagicMock, PropertyMock
+import json
+import os
+import time
-from august.activity import Activity
-from august.api import Api
-from august.exceptions import AugustApiHTTPError
+from asynctest import mock
+from asynctest.mock import CoroutineMock, MagicMock, PropertyMock
+from august.activity import (
+ ACTIVITY_ACTIONS_DOOR_OPERATION,
+ ACTIVITY_ACTIONS_DOORBELL_DING,
+ ACTIVITY_ACTIONS_DOORBELL_MOTION,
+ ACTIVITY_ACTIONS_DOORBELL_VIEW,
+ ACTIVITY_ACTIONS_LOCK_OPERATION,
+ DoorbellDingActivity,
+ DoorbellMotionActivity,
+ DoorbellViewActivity,
+ DoorOperationActivity,
+ LockOperationActivity,
+)
+from august.authenticator import AuthenticationState
+from august.doorbell import Doorbell, DoorbellDetail
from august.lock import Lock, LockDetail
-from homeassistant.components.august import AugustData
-from homeassistant.components.august.binary_sensor import AugustDoorBinarySensor
-from homeassistant.components.august.lock import AugustLock
-from homeassistant.util import dt
+from homeassistant.components.august import (
+ CONF_LOGIN_METHOD,
+ CONF_PASSWORD,
+ CONF_USERNAME,
+ DOMAIN,
+)
+from homeassistant.setup import async_setup_component
+
+from tests.common import load_fixture
-class MockAugustApiFailing(Api):
- """A mock for py-august Api class that always has an AugustApiHTTPError."""
-
- def _call_api(self, *args, **kwargs):
- """Mock the time activity started."""
- raise AugustApiHTTPError("This should bubble up as its user consumable")
+def _mock_get_config():
+ """Return a default august config."""
+ return {
+ DOMAIN: {
+ CONF_LOGIN_METHOD: "email",
+ CONF_USERNAME: "mocked_username",
+ CONF_PASSWORD: "mocked_password",
+ }
+ }
-class MockActivity(Activity):
- """A mock for py-august Activity class."""
-
- def __init__(
- self, action=None, activity_start_timestamp=None, activity_end_timestamp=None
- ):
- """Init the py-august Activity class mock."""
- self._action = action
- self._activity_start_timestamp = activity_start_timestamp
- self._activity_end_timestamp = activity_end_timestamp
-
- @property
- def activity_start_time(self):
- """Mock the time activity started."""
- return datetime.datetime.fromtimestamp(self._activity_start_timestamp)
-
- @property
- def activity_end_time(self):
- """Mock the time activity ended."""
- return datetime.datetime.fromtimestamp(self._activity_end_timestamp)
-
- @property
- def action(self):
- """Mock the action."""
- return self._action
-
-
-class MockAugustComponentDoorBinarySensor(AugustDoorBinarySensor):
- """A mock for august component AugustDoorBinarySensor class."""
-
- def _update_door_state(self, door_state, activity_start_time_utc):
- """Mock updating the lock status."""
- self._data.set_last_door_state_update_time_utc(
- self._door.device_id, activity_start_time_utc
- )
- self.last_update_door_state = {}
- self.last_update_door_state["door_state"] = door_state
- self.last_update_door_state["activity_start_time_utc"] = activity_start_time_utc
-
-
-class MockAugustComponentLock(AugustLock):
- """A mock for august component AugustLock class."""
-
- def _update_lock_status(self, lock_status, activity_start_time_utc):
- """Mock updating the lock status."""
- self._data.set_last_lock_status_update_time_utc(
- self._lock.device_id, activity_start_time_utc
- )
- self.last_update_lock_status = {}
- self.last_update_lock_status["lock_status"] = lock_status
- self.last_update_lock_status[
- "activity_start_time_utc"
- ] = activity_start_time_utc
-
-
-class MockAugustComponentData(AugustData):
- """A wrapper to mock AugustData."""
-
- # AugustData support multiple locks, however for the purposes of
- # mocking we currently only mock one lockid
-
- def __init__(
- self,
- last_lock_status_update_timestamp=1,
- last_door_state_update_timestamp=1,
- api=MockAugustApiFailing(),
- access_token="mocked_access_token",
- locks=[],
- doorbells=[],
- ):
- """Mock AugustData."""
- self._last_lock_status_update_time_utc = dt.as_utc(
- datetime.datetime.fromtimestamp(last_lock_status_update_timestamp)
- )
- self._last_door_state_update_time_utc = dt.as_utc(
- datetime.datetime.fromtimestamp(last_lock_status_update_timestamp)
- )
- self._api = api
- self._access_token = access_token
- self._locks = locks
- self._doorbells = doorbells
- self._lock_status_by_id = {}
- self._lock_last_status_update_time_utc_by_id = {}
-
- def set_mocked_locks(self, locks):
- """Set lock mocks."""
- self._locks = locks
-
- def set_mocked_doorbells(self, doorbells):
- """Set doorbell mocks."""
- self._doorbells = doorbells
-
- def get_last_lock_status_update_time_utc(self, device_id):
- """Mock to get last lock status update time."""
- return self._last_lock_status_update_time_utc
-
- def set_last_lock_status_update_time_utc(self, device_id, update_time):
- """Mock to set last lock status update time."""
- self._last_lock_status_update_time_utc = update_time
-
- def get_last_door_state_update_time_utc(self, device_id):
- """Mock to get last door state update time."""
- return self._last_door_state_update_time_utc
-
- def set_last_door_state_update_time_utc(self, device_id, update_time):
- """Mock to set last door state update time."""
- self._last_door_state_update_time_utc = update_time
-
-
-def _mock_august_authenticator():
- authenticator = MagicMock(name="august.authenticator")
- authenticator.should_refresh = MagicMock(
- name="august.authenticator.should_refresh", return_value=0
+@mock.patch("homeassistant.components.august.gateway.ApiAsync")
+@mock.patch(
+ "homeassistant.components.august.gateway.AuthenticatorAsync.async_authenticate"
+)
+async def _mock_setup_august(hass, api_instance, authenticate_mock, api_mock):
+ """Set up august integration."""
+ authenticate_mock.side_effect = MagicMock(
+ return_value=_mock_august_authentication("original_token", 1234)
)
- authenticator.refresh_access_token = MagicMock(
- name="august.authenticator.refresh_access_token"
- )
- return authenticator
+ api_mock.return_value = api_instance
+ assert await async_setup_component(hass, DOMAIN, _mock_get_config())
+ await hass.async_block_till_done()
+ return True
+
+
+async def _create_august_with_devices(
+ hass, devices, api_call_side_effects=None, activities=None
+):
+ if api_call_side_effects is None:
+ api_call_side_effects = {}
+
+ device_data = {
+ "doorbells": [],
+ "locks": [],
+ }
+ for device in devices:
+ if isinstance(device, LockDetail):
+ device_data["locks"].append(
+ {"base": _mock_august_lock(device.device_id), "detail": device}
+ )
+ elif isinstance(device, DoorbellDetail):
+ device_data["doorbells"].append(
+ {"base": _mock_august_doorbell(device.device_id), "detail": device}
+ )
+ else:
+ raise ValueError
+
+ def _get_device_detail(device_type, device_id):
+ for device in device_data[device_type]:
+ if device["detail"].device_id == device_id:
+ return device["detail"]
+ raise ValueError
+
+ def _get_base_devices(device_type):
+ base_devices = []
+ for device in device_data[device_type]:
+ base_devices.append(device["base"])
+ return base_devices
+
+ def get_lock_detail_side_effect(access_token, device_id):
+ return _get_device_detail("locks", device_id)
+
+ def get_doorbell_detail_side_effect(access_token, device_id):
+ return _get_device_detail("doorbells", device_id)
+
+ def get_operable_locks_side_effect(access_token):
+ return _get_base_devices("locks")
+
+ def get_doorbells_side_effect(access_token):
+ return _get_base_devices("doorbells")
+
+ def get_house_activities_side_effect(access_token, house_id, limit=10):
+ if activities is not None:
+ return activities
+ return []
+
+ def lock_return_activities_side_effect(access_token, device_id):
+ lock = _get_device_detail("locks", device_id)
+ return [
+ _mock_lock_operation_activity(lock, "lock"),
+ _mock_door_operation_activity(lock, "doorclosed"),
+ ]
+
+ def unlock_return_activities_side_effect(access_token, device_id):
+ lock = _get_device_detail("locks", device_id)
+ return [
+ _mock_lock_operation_activity(lock, "unlock"),
+ _mock_door_operation_activity(lock, "dooropen"),
+ ]
+
+ if "get_lock_detail" not in api_call_side_effects:
+ api_call_side_effects["get_lock_detail"] = get_lock_detail_side_effect
+ if "get_doorbell_detail" not in api_call_side_effects:
+ api_call_side_effects["get_doorbell_detail"] = get_doorbell_detail_side_effect
+ if "get_operable_locks" not in api_call_side_effects:
+ api_call_side_effects["get_operable_locks"] = get_operable_locks_side_effect
+ if "get_doorbells" not in api_call_side_effects:
+ api_call_side_effects["get_doorbells"] = get_doorbells_side_effect
+ if "get_house_activities" not in api_call_side_effects:
+ api_call_side_effects["get_house_activities"] = get_house_activities_side_effect
+ if "lock_return_activities" not in api_call_side_effects:
+ api_call_side_effects[
+ "lock_return_activities"
+ ] = lock_return_activities_side_effect
+ if "unlock_return_activities" not in api_call_side_effects:
+ api_call_side_effects[
+ "unlock_return_activities"
+ ] = unlock_return_activities_side_effect
+
+ return await _mock_setup_august_with_api_side_effects(hass, api_call_side_effects)
+
+
+async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects):
+ api_instance = MagicMock(name="Api")
+
+ if api_call_side_effects["get_lock_detail"]:
+ type(api_instance).async_get_lock_detail = CoroutineMock(
+ side_effect=api_call_side_effects["get_lock_detail"]
+ )
+
+ if api_call_side_effects["get_operable_locks"]:
+ type(api_instance).async_get_operable_locks = CoroutineMock(
+ side_effect=api_call_side_effects["get_operable_locks"]
+ )
+
+ if api_call_side_effects["get_doorbells"]:
+ type(api_instance).async_get_doorbells = CoroutineMock(
+ side_effect=api_call_side_effects["get_doorbells"]
+ )
+
+ if api_call_side_effects["get_doorbell_detail"]:
+ type(api_instance).async_get_doorbell_detail = CoroutineMock(
+ side_effect=api_call_side_effects["get_doorbell_detail"]
+ )
+
+ if api_call_side_effects["get_house_activities"]:
+ type(api_instance).async_get_house_activities = CoroutineMock(
+ side_effect=api_call_side_effects["get_house_activities"]
+ )
+
+ if api_call_side_effects["lock_return_activities"]:
+ type(api_instance).async_lock_return_activities = CoroutineMock(
+ side_effect=api_call_side_effects["lock_return_activities"]
+ )
+
+ if api_call_side_effects["unlock_return_activities"]:
+ type(api_instance).async_unlock_return_activities = CoroutineMock(
+ side_effect=api_call_side_effects["unlock_return_activities"]
+ )
+
+ return await _mock_setup_august(hass, api_instance)
def _mock_august_authentication(token_text, token_timestamp):
authentication = MagicMock(name="august.authentication")
+ type(authentication).state = PropertyMock(
+ return_value=AuthenticationState.AUTHENTICATED
+ )
type(authentication).access_token = PropertyMock(return_value=token_text)
type(authentication).access_token_expires = PropertyMock(
return_value=token_timestamp
@@ -154,6 +202,32 @@ def _mock_august_lock(lockid="mocklockid1", houseid="mockhouseid1"):
return Lock(lockid, _mock_august_lock_data(lockid=lockid, houseid=houseid))
+def _mock_august_doorbell(deviceid="mockdeviceid1", houseid="mockhouseid1"):
+ return Doorbell(
+ deviceid, _mock_august_doorbell_data(deviceid=deviceid, houseid=houseid)
+ )
+
+
+def _mock_august_doorbell_data(deviceid="mockdeviceid1", houseid="mockhouseid1"):
+ return {
+ "_id": deviceid,
+ "DeviceID": deviceid,
+ "name": deviceid + " Name",
+ "HouseID": houseid,
+ "UserType": "owner",
+ "serialNumber": "mockserial",
+ "battery": 90,
+ "status": "standby",
+ "currentFirmwareVersion": "mockfirmware",
+ "Bridge": {
+ "_id": "bridgeid1",
+ "firmwareVersion": "mockfirm",
+ "operative": True,
+ },
+ "LockStatus": {"doorState": "open"},
+ }
+
+
def _mock_august_lock_data(lockid="mocklockid1", houseid="mockhouseid1"):
return {
"_id": lockid,
@@ -173,23 +247,85 @@ def _mock_august_lock_data(lockid="mocklockid1", houseid="mockhouseid1"):
}
-def _mock_operative_august_lock_detail(lockid):
- operative_lock_detail_data = _mock_august_lock_data(lockid=lockid)
- return LockDetail(operative_lock_detail_data)
+async def _mock_operative_august_lock_detail(hass):
+ return await _mock_lock_from_fixture(hass, "get_lock.online.json")
-def _mock_inoperative_august_lock_detail(lockid):
- inoperative_lock_detail_data = _mock_august_lock_data(lockid=lockid)
- del inoperative_lock_detail_data["Bridge"]
- return LockDetail(inoperative_lock_detail_data)
+async def _mock_inoperative_august_lock_detail(hass):
+ return await _mock_lock_from_fixture(hass, "get_lock.offline.json")
-def _mock_doorsense_enabled_august_lock_detail(lockid):
- doorsense_lock_detail_data = _mock_august_lock_data(lockid=lockid)
- return LockDetail(doorsense_lock_detail_data)
+async def _mock_activities_from_fixture(hass, path):
+ json_dict = await _load_json_fixture(hass, path)
+ activities = []
+ for activity_json in json_dict:
+ activity = _activity_from_dict(activity_json)
+ if activity:
+ activities.append(activity)
+
+ return activities
-def _mock_doorsense_missing_august_lock_detail(lockid):
- doorsense_lock_detail_data = _mock_august_lock_data(lockid=lockid)
- del doorsense_lock_detail_data["LockStatus"]["doorState"]
- return LockDetail(doorsense_lock_detail_data)
+async def _mock_lock_from_fixture(hass, path):
+ json_dict = await _load_json_fixture(hass, path)
+ return LockDetail(json_dict)
+
+
+async def _mock_doorbell_from_fixture(hass, path):
+ json_dict = await _load_json_fixture(hass, path)
+ return DoorbellDetail(json_dict)
+
+
+async def _load_json_fixture(hass, path):
+ fixture = await hass.async_add_executor_job(
+ load_fixture, os.path.join("august", path)
+ )
+ return json.loads(fixture)
+
+
+async def _mock_doorsense_enabled_august_lock_detail(hass):
+ return await _mock_lock_from_fixture(hass, "get_lock.online_with_doorsense.json")
+
+
+async def _mock_doorsense_missing_august_lock_detail(hass):
+ return await _mock_lock_from_fixture(hass, "get_lock.online_missing_doorsense.json")
+
+
+def _mock_lock_operation_activity(lock, action):
+ return LockOperationActivity(
+ {
+ "dateTime": time.time() * 1000,
+ "deviceID": lock.device_id,
+ "deviceType": "lock",
+ "action": action,
+ }
+ )
+
+
+def _mock_door_operation_activity(lock, action):
+ return DoorOperationActivity(
+ {
+ "dateTime": time.time() * 1000,
+ "deviceID": lock.device_id,
+ "deviceType": "lock",
+ "action": action,
+ }
+ )
+
+
+def _activity_from_dict(activity_dict):
+ action = activity_dict.get("action")
+
+ activity_dict["dateTime"] = time.time() * 1000
+
+ if action in ACTIVITY_ACTIONS_DOORBELL_DING:
+ return DoorbellDingActivity(activity_dict)
+ if action in ACTIVITY_ACTIONS_DOORBELL_MOTION:
+ return DoorbellMotionActivity(activity_dict)
+ if action in ACTIVITY_ACTIONS_DOORBELL_VIEW:
+ return DoorbellViewActivity(activity_dict)
+ if action in ACTIVITY_ACTIONS_LOCK_OPERATION:
+ return LockOperationActivity(activity_dict)
+ if action in ACTIVITY_ACTIONS_DOOR_OPERATION:
+ return DoorOperationActivity(activity_dict)
+ return None
diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py
index 0fbd120ea8b..87fc6e5eec9 100644
--- a/tests/components/august/test_binary_sensor.py
+++ b/tests/components/august/test_binary_sensor.py
@@ -1,89 +1,130 @@
-"""The lock tests for the august platform."""
+"""The binary_sensor tests for the august platform."""
-import datetime
-
-from august.activity import ACTION_DOOR_CLOSED, ACTION_DOOR_OPEN
-from august.lock import LockDoorStatus
-
-from homeassistant.util import dt
+from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ SERVICE_LOCK,
+ SERVICE_UNLOCK,
+ STATE_OFF,
+ STATE_ON,
+ STATE_UNAVAILABLE,
+)
from tests.components.august.mocks import (
- MockActivity,
- MockAugustComponentData,
- MockAugustComponentDoorBinarySensor,
- _mock_august_lock,
+ _create_august_with_devices,
+ _mock_activities_from_fixture,
+ _mock_doorbell_from_fixture,
+ _mock_lock_from_fixture,
)
-def test__sync_door_activity_doored_via_dooropen():
- """Test _sync_door_activity dooropen."""
- data = MockAugustComponentData(last_door_state_update_timestamp=1)
- lock = _mock_august_lock()
- data.set_mocked_locks([lock])
- door = MockAugustComponentDoorBinarySensor(data, "door_open", lock)
- door_activity_start_timestamp = 1234
- door_activity = MockActivity(
- action=ACTION_DOOR_OPEN,
- activity_start_timestamp=door_activity_start_timestamp,
- activity_end_timestamp=5678,
+async def test_doorsense(hass):
+ """Test creation of a lock with doorsense and bridge."""
+ lock_one = await _mock_lock_from_fixture(
+ hass, "get_lock.online_with_doorsense.json"
)
- door._sync_door_activity(door_activity)
- assert door.last_update_door_state["door_state"] == LockDoorStatus.OPEN
- assert door.last_update_door_state["activity_start_time_utc"] == dt.as_utc(
- datetime.datetime.fromtimestamp(door_activity_start_timestamp)
+ await _create_august_with_devices(hass, [lock_one])
+
+ binary_sensor_online_with_doorsense_name = hass.states.get(
+ "binary_sensor.online_with_doorsense_name_open"
)
+ assert binary_sensor_online_with_doorsense_name.state == STATE_ON
+
+ data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"}
+ assert await hass.services.async_call(
+ LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True
+ )
+ await hass.async_block_till_done()
+
+ binary_sensor_online_with_doorsense_name = hass.states.get(
+ "binary_sensor.online_with_doorsense_name_open"
+ )
+ assert binary_sensor_online_with_doorsense_name.state == STATE_ON
+
+ assert await hass.services.async_call(
+ LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True
+ )
+ await hass.async_block_till_done()
+
+ binary_sensor_online_with_doorsense_name = hass.states.get(
+ "binary_sensor.online_with_doorsense_name_open"
+ )
+ assert binary_sensor_online_with_doorsense_name.state == STATE_OFF
-def test__sync_door_activity_doorclosed():
- """Test _sync_door_activity doorclosed."""
- data = MockAugustComponentData(last_door_state_update_timestamp=1)
- lock = _mock_august_lock()
- data.set_mocked_locks([lock])
- door = MockAugustComponentDoorBinarySensor(data, "door_open", lock)
- door_activity_timestamp = 1234
- door_activity = MockActivity(
- action=ACTION_DOOR_CLOSED,
- activity_start_timestamp=door_activity_timestamp,
- activity_end_timestamp=door_activity_timestamp,
+async def test_create_doorbell(hass):
+ """Test creation of a doorbell."""
+ doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json")
+ await _create_august_with_devices(hass, [doorbell_one])
+
+ binary_sensor_k98gidt45gul_name_motion = hass.states.get(
+ "binary_sensor.k98gidt45gul_name_motion"
)
- door._sync_door_activity(door_activity)
- assert door.last_update_door_state["door_state"] == LockDoorStatus.CLOSED
- assert door.last_update_door_state["activity_start_time_utc"] == dt.as_utc(
- datetime.datetime.fromtimestamp(door_activity_timestamp)
+ assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF
+ binary_sensor_k98gidt45gul_name_online = hass.states.get(
+ "binary_sensor.k98gidt45gul_name_online"
)
+ assert binary_sensor_k98gidt45gul_name_online.state == STATE_ON
+ binary_sensor_k98gidt45gul_name_ding = hass.states.get(
+ "binary_sensor.k98gidt45gul_name_ding"
+ )
+ assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF
+ binary_sensor_k98gidt45gul_name_motion = hass.states.get(
+ "binary_sensor.k98gidt45gul_name_motion"
+ )
+ assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF
-def test__sync_door_activity_ignores_old_data():
- """Test _sync_door_activity dooropen then expired doorclosed."""
- data = MockAugustComponentData(last_door_state_update_timestamp=1)
- lock = _mock_august_lock()
- data.set_mocked_locks([lock])
- door = MockAugustComponentDoorBinarySensor(data, "door_open", lock)
- first_door_activity_timestamp = 1234
- door_activity = MockActivity(
- action=ACTION_DOOR_OPEN,
- activity_start_timestamp=first_door_activity_timestamp,
- activity_end_timestamp=first_door_activity_timestamp,
- )
- door._sync_door_activity(door_activity)
- assert door.last_update_door_state["door_state"] == LockDoorStatus.OPEN
- assert door.last_update_door_state["activity_start_time_utc"] == dt.as_utc(
- datetime.datetime.fromtimestamp(first_door_activity_timestamp)
- )
+async def test_create_doorbell_offline(hass):
+ """Test creation of a doorbell that is offline."""
+ doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json")
+ await _create_august_with_devices(hass, [doorbell_one])
- # Now we do the update with an older start time to
- # make sure it ignored
- data.set_last_door_state_update_time_utc(
- lock.device_id, dt.as_utc(datetime.datetime.fromtimestamp(1000))
+ binary_sensor_tmt100_name_motion = hass.states.get(
+ "binary_sensor.tmt100_name_motion"
)
- door_activity_timestamp = 2
- door_activity = MockActivity(
- action=ACTION_DOOR_CLOSED,
- activity_start_timestamp=door_activity_timestamp,
- activity_end_timestamp=door_activity_timestamp,
+ assert binary_sensor_tmt100_name_motion.state == STATE_UNAVAILABLE
+ binary_sensor_tmt100_name_online = hass.states.get(
+ "binary_sensor.tmt100_name_online"
)
- door._sync_door_activity(door_activity)
- assert door.last_update_door_state["door_state"] == LockDoorStatus.OPEN
- assert door.last_update_door_state["activity_start_time_utc"] == dt.as_utc(
- datetime.datetime.fromtimestamp(first_door_activity_timestamp)
+ assert binary_sensor_tmt100_name_online.state == STATE_OFF
+ binary_sensor_tmt100_name_ding = hass.states.get("binary_sensor.tmt100_name_ding")
+ assert binary_sensor_tmt100_name_ding.state == STATE_UNAVAILABLE
+
+
+async def test_create_doorbell_with_motion(hass):
+ """Test creation of a doorbell."""
+ doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json")
+ activities = await _mock_activities_from_fixture(
+ hass, "get_activity.doorbell_motion.json"
)
+ await _create_august_with_devices(hass, [doorbell_one], activities=activities)
+
+ binary_sensor_k98gidt45gul_name_motion = hass.states.get(
+ "binary_sensor.k98gidt45gul_name_motion"
+ )
+ assert binary_sensor_k98gidt45gul_name_motion.state == STATE_ON
+ binary_sensor_k98gidt45gul_name_online = hass.states.get(
+ "binary_sensor.k98gidt45gul_name_online"
+ )
+ assert binary_sensor_k98gidt45gul_name_online.state == STATE_ON
+ binary_sensor_k98gidt45gul_name_ding = hass.states.get(
+ "binary_sensor.k98gidt45gul_name_ding"
+ )
+ assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF
+
+
+async def test_doorbell_device_registry(hass):
+ """Test creation of a lock with doorsense and bridge ands up in the registry."""
+ doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json")
+ await _create_august_with_devices(hass, [doorbell_one])
+
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+
+ reg_device = device_registry.async_get_device(
+ identifiers={("august", "tmt100")}, connections=set()
+ )
+ assert reg_device.model == "hydra1"
+ assert reg_device.name == "tmt100 Name"
+ assert reg_device.manufacturer == "August"
+ assert reg_device.sw_version == "3.1.0-HYDRC75+201909251139"
diff --git a/tests/components/august/test_camera.py b/tests/components/august/test_camera.py
new file mode 100644
index 00000000000..e47bafece42
--- /dev/null
+++ b/tests/components/august/test_camera.py
@@ -0,0 +1,35 @@
+"""The camera tests for the august platform."""
+
+from asynctest import mock
+
+from homeassistant.const import STATE_IDLE
+
+from tests.components.august.mocks import (
+ _create_august_with_devices,
+ _mock_doorbell_from_fixture,
+)
+
+
+async def test_create_doorbell(hass, aiohttp_client):
+ """Test creation of a doorbell."""
+ doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json")
+
+ with mock.patch.object(
+ doorbell_one, "async_get_doorbell_image", create=False, return_value="image"
+ ):
+ await _create_august_with_devices(hass, [doorbell_one])
+
+ camera_k98gidt45gul_name_camera = hass.states.get(
+ "camera.k98gidt45gul_name_camera"
+ )
+ assert camera_k98gidt45gul_name_camera.state == STATE_IDLE
+
+ url = hass.states.get("camera.k98gidt45gul_name_camera").attributes[
+ "entity_picture"
+ ]
+
+ client = await aiohttp_client(hass.http.app)
+ resp = await client.get(url)
+ assert resp.status == 200
+ body = await resp.text()
+ assert body == "image"
diff --git a/tests/components/august/test_config_flow.py b/tests/components/august/test_config_flow.py
new file mode 100644
index 00000000000..8d29ba650fa
--- /dev/null
+++ b/tests/components/august/test_config_flow.py
@@ -0,0 +1,195 @@
+"""Test the August config flow."""
+from asynctest import patch
+from august.authenticator import ValidationResult
+
+from homeassistant import config_entries, setup
+from homeassistant.components.august.const import (
+ CONF_ACCESS_TOKEN_CACHE_FILE,
+ CONF_INSTALL_ID,
+ CONF_LOGIN_METHOD,
+ DOMAIN,
+ VERIFICATION_CODE_KEY,
+)
+from homeassistant.components.august.exceptions import (
+ CannotConnect,
+ InvalidAuth,
+ RequireValidation,
+)
+from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
+
+
+async def test_form(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch(
+ "homeassistant.components.august.config_flow.AugustGateway.async_authenticate",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.august.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.august.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_LOGIN_METHOD: "email",
+ CONF_USERNAME: "my@email.tld",
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "my@email.tld"
+ assert result2["data"] == {
+ CONF_LOGIN_METHOD: "email",
+ CONF_USERNAME: "my@email.tld",
+ CONF_PASSWORD: "test-password",
+ CONF_INSTALL_ID: None,
+ CONF_TIMEOUT: 10,
+ CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_invalid_auth(hass):
+ """Test we handle invalid auth."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.august.config_flow.AugustGateway.async_authenticate",
+ side_effect=InvalidAuth,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_LOGIN_METHOD: "email",
+ CONF_USERNAME: "my@email.tld",
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "invalid_auth"}
+
+
+async def test_form_cannot_connect(hass):
+ """Test we handle cannot connect error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.august.config_flow.AugustGateway.async_authenticate",
+ side_effect=CannotConnect,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_LOGIN_METHOD: "email",
+ CONF_USERNAME: "my@email.tld",
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_needs_validate(hass):
+ """Test we present validation when we need to validate."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.august.config_flow.AugustGateway.async_authenticate",
+ side_effect=RequireValidation,
+ ), patch(
+ "homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code",
+ return_value=True,
+ ) as mock_send_verification_code:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_LOGIN_METHOD: "email",
+ CONF_USERNAME: "my@email.tld",
+ CONF_PASSWORD: "test-password",
+ },
+ )
+
+ assert len(mock_send_verification_code.mock_calls) == 1
+ assert result2["type"] == "form"
+ assert result2["errors"] is None
+ assert result2["step_id"] == "validation"
+
+ # Try with the WRONG verification code give us the form back again
+ with patch(
+ "homeassistant.components.august.config_flow.AugustGateway.async_authenticate",
+ side_effect=RequireValidation,
+ ), patch(
+ "homeassistant.components.august.gateway.AuthenticatorAsync.async_validate_verification_code",
+ return_value=ValidationResult.INVALID_VERIFICATION_CODE,
+ ) as mock_validate_verification_code, patch(
+ "homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code",
+ return_value=True,
+ ) as mock_send_verification_code, patch(
+ "homeassistant.components.august.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.august.async_setup_entry", return_value=True
+ ) as mock_setup_entry:
+ result3 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {VERIFICATION_CODE_KEY: "incorrect"},
+ )
+
+ # Make sure we do not resend the code again
+ # so they have a chance to retry
+ assert len(mock_send_verification_code.mock_calls) == 0
+ assert len(mock_validate_verification_code.mock_calls) == 1
+ assert result3["type"] == "form"
+ assert result3["errors"] is None
+ assert result3["step_id"] == "validation"
+
+ # Try with the CORRECT verification code and we setup
+ with patch(
+ "homeassistant.components.august.config_flow.AugustGateway.async_authenticate",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.august.gateway.AuthenticatorAsync.async_validate_verification_code",
+ return_value=ValidationResult.VALIDATED,
+ ) as mock_validate_verification_code, patch(
+ "homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code",
+ return_value=True,
+ ) as mock_send_verification_code, patch(
+ "homeassistant.components.august.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.august.async_setup_entry", return_value=True
+ ) as mock_setup_entry:
+ result4 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {VERIFICATION_CODE_KEY: "correct"},
+ )
+
+ assert len(mock_send_verification_code.mock_calls) == 0
+ assert len(mock_validate_verification_code.mock_calls) == 1
+ assert result4["type"] == "create_entry"
+ assert result4["title"] == "my@email.tld"
+ assert result4["data"] == {
+ CONF_LOGIN_METHOD: "email",
+ CONF_USERNAME: "my@email.tld",
+ CONF_PASSWORD: "test-password",
+ CONF_INSTALL_ID: None,
+ CONF_TIMEOUT: 10,
+ CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
diff --git a/tests/components/august/test_gateway.py b/tests/components/august/test_gateway.py
new file mode 100644
index 00000000000..f5fe35b4b19
--- /dev/null
+++ b/tests/components/august/test_gateway.py
@@ -0,0 +1,51 @@
+"""The gateway tests for the august platform."""
+from unittest.mock import MagicMock
+
+from asynctest import mock
+
+from homeassistant.components.august.const import DOMAIN
+from homeassistant.components.august.gateway import AugustGateway
+
+from tests.components.august.mocks import _mock_august_authentication, _mock_get_config
+
+
+async def test_refresh_access_token(hass):
+ """Test token refreshes."""
+ await _patched_refresh_access_token(hass, "new_token", 5678)
+
+
+@mock.patch(
+ "homeassistant.components.august.gateway.AuthenticatorAsync.async_authenticate"
+)
+@mock.patch("homeassistant.components.august.gateway.AuthenticatorAsync.should_refresh")
+@mock.patch(
+ "homeassistant.components.august.gateway.AuthenticatorAsync.async_refresh_access_token"
+)
+async def _patched_refresh_access_token(
+ hass,
+ new_token,
+ new_token_expire_time,
+ refresh_access_token_mock,
+ should_refresh_mock,
+ authenticate_mock,
+):
+ authenticate_mock.side_effect = MagicMock(
+ return_value=_mock_august_authentication("original_token", 1234)
+ )
+ august_gateway = AugustGateway(hass)
+ mocked_config = _mock_get_config()
+ await august_gateway.async_setup(mocked_config[DOMAIN])
+ await august_gateway.async_authenticate()
+
+ should_refresh_mock.return_value = False
+ await august_gateway.async_refresh_access_token_if_needed()
+ refresh_access_token_mock.assert_not_called()
+
+ should_refresh_mock.return_value = True
+ refresh_access_token_mock.return_value = _mock_august_authentication(
+ new_token, new_token_expire_time
+ )
+ await august_gateway.async_refresh_access_token_if_needed()
+ refresh_access_token_mock.assert_called()
+ assert august_gateway.access_token == new_token
+ assert august_gateway.authentication.access_token_expires == new_token_expire_time
diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py
index 3a43a0a841a..c287a26b34f 100644
--- a/tests/components/august/test_init.py
+++ b/tests/components/august/test_init.py
@@ -1,137 +1,169 @@
"""The tests for the august platform."""
import asyncio
-from unittest.mock import MagicMock
-from august.lock import LockDetail
-from requests import RequestException
+from asynctest import patch
+from august.exceptions import AugustApiAIOHTTPError
-from homeassistant.components import august
+from homeassistant import setup
+from homeassistant.components.august.const import (
+ CONF_ACCESS_TOKEN_CACHE_FILE,
+ CONF_INSTALL_ID,
+ CONF_LOGIN_METHOD,
+ DEFAULT_AUGUST_CONFIG_FILE,
+ DOMAIN,
+)
+from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
+from homeassistant.config_entries import ENTRY_STATE_SETUP_RETRY
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ CONF_PASSWORD,
+ CONF_TIMEOUT,
+ CONF_USERNAME,
+ SERVICE_LOCK,
+ SERVICE_UNLOCK,
+ STATE_LOCKED,
+ STATE_ON,
+)
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.setup import async_setup_component
+from tests.common import MockConfigEntry
from tests.components.august.mocks import (
- MockAugustApiFailing,
- MockAugustComponentData,
- _mock_august_authentication,
- _mock_august_authenticator,
- _mock_august_lock,
+ _create_august_with_devices,
_mock_doorsense_enabled_august_lock_detail,
_mock_doorsense_missing_august_lock_detail,
+ _mock_get_config,
_mock_inoperative_august_lock_detail,
_mock_operative_august_lock_detail,
)
-def test_get_lock_name():
- """Get the lock name from August data."""
- data = MockAugustComponentData(last_lock_status_update_timestamp=1)
- lock = _mock_august_lock()
- data.set_mocked_locks([lock])
- assert data.get_lock_name("mocklockid1") == "mocklockid1 Name"
+async def test_august_is_offline(hass):
+ """Config entry state is ENTRY_STATE_SETUP_RETRY when august is offline."""
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN, data=_mock_get_config()[DOMAIN], title="August august",
+ )
+ config_entry.add_to_hass(hass)
+
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ with patch(
+ "august.authenticator_async.AuthenticatorAsync.async_authenticate",
+ side_effect=asyncio.TimeoutError,
+ ):
+ await hass.config_entries.async_setup(config_entry.entry_id)
+
+ await hass.async_block_till_done()
+ assert config_entry.state == ENTRY_STATE_SETUP_RETRY
-def test_unlock_throws_august_api_http_error():
- """Test unlock."""
- data = MockAugustComponentData(api=MockAugustApiFailing())
- lock = _mock_august_lock()
- data.set_mocked_locks([lock])
+async def test_unlock_throws_august_api_http_error(hass):
+ """Test unlock throws correct error on http error."""
+ mocked_lock_detail = await _mock_operative_august_lock_detail(hass)
+
+ def _unlock_return_activities_side_effect(access_token, device_id):
+ raise AugustApiAIOHTTPError("This should bubble up as its user consumable")
+
+ await _create_august_with_devices(
+ hass,
+ [mocked_lock_detail],
+ api_call_side_effects={
+ "unlock_return_activities": _unlock_return_activities_side_effect
+ },
+ )
last_err = None
+ data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"}
try:
- data.unlock("mocklockid1")
+ await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True)
except HomeAssistantError as err:
last_err = err
assert (
str(last_err)
- == "mocklockid1 Name: This should bubble up as its user consumable"
+ == "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user consumable"
)
-def test_lock_throws_august_api_http_error():
- """Test lock."""
- data = MockAugustComponentData(api=MockAugustApiFailing())
- lock = _mock_august_lock()
- data.set_mocked_locks([lock])
+async def test_lock_throws_august_api_http_error(hass):
+ """Test lock throws correct error on http error."""
+ mocked_lock_detail = await _mock_operative_august_lock_detail(hass)
+
+ def _lock_return_activities_side_effect(access_token, device_id):
+ raise AugustApiAIOHTTPError("This should bubble up as its user consumable")
+
+ await _create_august_with_devices(
+ hass,
+ [mocked_lock_detail],
+ api_call_side_effects={
+ "lock_return_activities": _lock_return_activities_side_effect
+ },
+ )
last_err = None
+ data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"}
try:
- data.unlock("mocklockid1")
+ await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True)
except HomeAssistantError as err:
last_err = err
assert (
str(last_err)
- == "mocklockid1 Name: This should bubble up as its user consumable"
+ == "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user consumable"
)
-def test_inoperative_locks_are_filtered_out():
+async def test_inoperative_locks_are_filtered_out(hass):
"""Ensure inoperative locks do not get setup."""
- august_operative_lock = _mock_operative_august_lock_detail("oplockid1")
- data = _create_august_data_with_lock_details(
- [august_operative_lock, _mock_inoperative_august_lock_detail("inoplockid1")]
+ august_operative_lock = await _mock_operative_august_lock_detail(hass)
+ august_inoperative_lock = await _mock_inoperative_august_lock_detail(hass)
+ await _create_august_with_devices(
+ hass, [august_operative_lock, august_inoperative_lock]
)
- assert len(data.locks) == 1
- assert data.locks[0].device_id == "oplockid1"
+ lock_abc_name = hass.states.get("lock.abc_name")
+ assert lock_abc_name is None
+ lock_a6697750d607098bae8d6baa11ef8063_name = hass.states.get(
+ "lock.a6697750d607098bae8d6baa11ef8063_name"
+ )
+ assert lock_a6697750d607098bae8d6baa11ef8063_name.state == STATE_LOCKED
-def test_lock_has_doorsense():
+async def test_lock_has_doorsense(hass):
"""Check to see if a lock has doorsense."""
- data = _create_august_data_with_lock_details(
- [
- _mock_doorsense_enabled_august_lock_detail("doorsenselock1"),
- _mock_doorsense_missing_august_lock_detail("nodoorsenselock1"),
- RequestException("mocked request error"),
- RequestException("mocked request error"),
- ]
+ doorsenselock = await _mock_doorsense_enabled_august_lock_detail(hass)
+ nodoorsenselock = await _mock_doorsense_missing_august_lock_detail(hass)
+ await _create_august_with_devices(hass, [doorsenselock, nodoorsenselock])
+
+ binary_sensor_online_with_doorsense_name_open = hass.states.get(
+ "binary_sensor.online_with_doorsense_name_open"
)
-
- assert data.lock_has_doorsense("doorsenselock1") is True
- assert data.lock_has_doorsense("nodoorsenselock1") is False
-
- # The api calls are mocked to fail on the second
- # run of async_get_lock_detail
- #
- # This will be switched to await data.async_get_lock_detail("doorsenselock1")
- # once we mock the full home assistant setup
- data._update_locks_detail()
- # doorsenselock1 should be false if we cannot tell due
- # to an api error
- assert data.lock_has_doorsense("doorsenselock1") is False
-
-
-async def test__refresh_access_token(hass):
- """Test refresh of the access token."""
- authentication = _mock_august_authentication("original_token", 1234)
- authenticator = _mock_august_authenticator()
- token_refresh_lock = asyncio.Lock()
-
- data = august.AugustData(
- hass, MagicMock(name="api"), authentication, authenticator, token_refresh_lock
+ assert binary_sensor_online_with_doorsense_name_open.state == STATE_ON
+ binary_sensor_missing_doorsense_id_name_open = hass.states.get(
+ "binary_sensor.missing_doorsense_id_name_open"
)
- await data._async_refresh_access_token_if_needed()
- authenticator.refresh_access_token.assert_not_called()
-
- authenticator.should_refresh.return_value = 1
- authenticator.refresh_access_token.return_value = _mock_august_authentication(
- "new_token", 5678
- )
- await data._async_refresh_access_token_if_needed()
- authenticator.refresh_access_token.assert_called()
- assert data._access_token == "new_token"
- assert data._access_token_expires == 5678
+ assert binary_sensor_missing_doorsense_id_name_open is None
-def _create_august_data_with_lock_details(lock_details):
- locks = []
- for lock in lock_details:
- if isinstance(lock, LockDetail):
- locks.append(_mock_august_lock(lock.device_id))
- authentication = _mock_august_authentication("original_token", 1234)
- authenticator = _mock_august_authenticator()
- token_refresh_lock = MagicMock()
- api = MagicMock()
- api.get_lock_status = MagicMock(return_value=(MagicMock(), MagicMock()))
- api.get_lock_detail = MagicMock(side_effect=lock_details)
- api.get_operable_locks = MagicMock(return_value=locks)
- api.get_doorbells = MagicMock(return_value=[])
- return august.AugustData(
- MagicMock(), api, authentication, authenticator, token_refresh_lock
- )
+async def test_set_up_from_yaml(hass):
+ """Test to make sure config is imported from yaml."""
+
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ with patch(
+ "homeassistant.components.august.async_setup_august", return_value=True,
+ ) as mock_setup_august, patch(
+ "homeassistant.components.august.config_flow.AugustGateway.async_authenticate",
+ return_value=True,
+ ):
+ assert await async_setup_component(hass, DOMAIN, _mock_get_config())
+ await hass.async_block_till_done()
+ assert len(mock_setup_august.mock_calls) == 1
+ call = mock_setup_august.call_args
+ args, kwargs = call
+ imported_config_entry = args[1]
+ # The import must use DEFAULT_AUGUST_CONFIG_FILE so they
+ # do not loose their token when config is migrated
+ assert imported_config_entry.data == {
+ CONF_ACCESS_TOKEN_CACHE_FILE: DEFAULT_AUGUST_CONFIG_FILE,
+ CONF_INSTALL_ID: None,
+ CONF_LOGIN_METHOD: "email",
+ CONF_PASSWORD: "mocked_password",
+ CONF_TIMEOUT: None,
+ CONF_USERNAME: "mocked_username",
+ }
diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py
index 8b036861899..4bd5509a216 100644
--- a/tests/components/august/test_lock.py
+++ b/tests/components/august/test_lock.py
@@ -1,110 +1,112 @@
"""The lock tests for the august platform."""
-import datetime
-
-from august.activity import (
- ACTION_LOCK_LOCK,
- ACTION_LOCK_ONETOUCHLOCK,
- ACTION_LOCK_UNLOCK,
+from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ SERVICE_LOCK,
+ SERVICE_UNLOCK,
+ STATE_LOCKED,
+ STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
+ STATE_UNLOCKED,
)
-from august.lock import LockStatus
-
-from homeassistant.util import dt
from tests.components.august.mocks import (
- MockActivity,
- MockAugustComponentData,
- MockAugustComponentLock,
- _mock_august_lock,
+ _create_august_with_devices,
+ _mock_activities_from_fixture,
+ _mock_doorsense_enabled_august_lock_detail,
+ _mock_lock_from_fixture,
)
-def test__sync_lock_activity_locked_via_onetouchlock():
- """Test _sync_lock_activity locking."""
- lock = _mocked_august_component_lock()
- lock_activity_start_timestamp = 1234
- lock_activity = MockActivity(
- action=ACTION_LOCK_ONETOUCHLOCK,
- activity_start_timestamp=lock_activity_start_timestamp,
- activity_end_timestamp=5678,
+async def test_lock_device_registry(hass):
+ """Test creation of a lock with doorsense and bridge ands up in the registry."""
+ lock_one = await _mock_doorsense_enabled_august_lock_detail(hass)
+ await _create_august_with_devices(hass, [lock_one])
+
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+
+ reg_device = device_registry.async_get_device(
+ identifiers={("august", "online_with_doorsense")}, connections=set()
)
- lock._sync_lock_activity(lock_activity)
- assert lock.last_update_lock_status["lock_status"] == LockStatus.LOCKED
- assert lock.last_update_lock_status["activity_start_time_utc"] == dt.as_utc(
- datetime.datetime.fromtimestamp(lock_activity_start_timestamp)
+ assert reg_device.model == "AUG-MD01"
+ assert reg_device.sw_version == "undefined-4.3.0-1.8.14"
+ assert reg_device.name == "online_with_doorsense Name"
+ assert reg_device.manufacturer == "August"
+
+
+async def test_lock_changed_by(hass):
+ """Test creation of a lock with doorsense and bridge."""
+ lock_one = await _mock_doorsense_enabled_august_lock_detail(hass)
+
+ activities = await _mock_activities_from_fixture(hass, "get_activity.lock.json")
+ await _create_august_with_devices(hass, [lock_one], activities=activities)
+
+ lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
+
+ assert lock_online_with_doorsense_name.state == STATE_LOCKED
+
+ assert (
+ lock_online_with_doorsense_name.attributes.get("changed_by")
+ == "Your favorite elven princess"
)
-def test__sync_lock_activity_locked_via_lock():
- """Test _sync_lock_activity locking."""
- lock = _mocked_august_component_lock()
- lock_activity_start_timestamp = 1234
- lock_activity = MockActivity(
- action=ACTION_LOCK_LOCK,
- activity_start_timestamp=lock_activity_start_timestamp,
- activity_end_timestamp=5678,
+async def test_one_lock_operation(hass):
+ """Test creation of a lock with doorsense and bridge."""
+ lock_one = await _mock_doorsense_enabled_august_lock_detail(hass)
+ await _create_august_with_devices(hass, [lock_one])
+
+ lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
+
+ assert lock_online_with_doorsense_name.state == STATE_LOCKED
+
+ assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92
+ assert (
+ lock_online_with_doorsense_name.attributes.get("friendly_name")
+ == "online_with_doorsense Name"
)
- lock._sync_lock_activity(lock_activity)
- assert lock.last_update_lock_status["lock_status"] == LockStatus.LOCKED
- assert lock.last_update_lock_status["activity_start_time_utc"] == dt.as_utc(
- datetime.datetime.fromtimestamp(lock_activity_start_timestamp)
+
+ data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"}
+ assert await hass.services.async_call(
+ LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True
+ )
+
+ lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
+ assert lock_online_with_doorsense_name.state == STATE_UNLOCKED
+
+ assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92
+ assert (
+ lock_online_with_doorsense_name.attributes.get("friendly_name")
+ == "online_with_doorsense Name"
+ )
+
+ assert await hass.services.async_call(
+ LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True
+ )
+
+ lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name")
+ assert lock_online_with_doorsense_name.state == STATE_LOCKED
+
+ # No activity means it will be unavailable until the activity feed has data
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ lock_operator_sensor = entity_registry.async_get(
+ "sensor.online_with_doorsense_name_operator"
+ )
+ assert lock_operator_sensor
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").state
+ == STATE_UNAVAILABLE
)
-def test__sync_lock_activity_unlocked():
- """Test _sync_lock_activity unlocking."""
- lock = _mocked_august_component_lock()
- lock_activity_timestamp = 1234
- lock_activity = MockActivity(
- action=ACTION_LOCK_UNLOCK,
- activity_start_timestamp=lock_activity_timestamp,
- activity_end_timestamp=lock_activity_timestamp,
- )
- lock._sync_lock_activity(lock_activity)
- assert lock.last_update_lock_status["lock_status"] == LockStatus.UNLOCKED
- assert lock.last_update_lock_status["activity_start_time_utc"] == dt.as_utc(
- datetime.datetime.fromtimestamp(lock_activity_timestamp)
+async def test_one_lock_unknown_state(hass):
+ """Test creation of a lock with doorsense and bridge."""
+ lock_one = await _mock_lock_from_fixture(
+ hass, "get_lock.online.unknown_state.json",
)
+ await _create_august_with_devices(hass, [lock_one])
+ lock_brokenid_name = hass.states.get("lock.brokenid_name")
-def test__sync_lock_activity_ignores_old_data():
- """Test _sync_lock_activity unlocking."""
- data = MockAugustComponentData(last_lock_status_update_timestamp=1)
- august_lock = _mock_august_lock()
- data.set_mocked_locks([august_lock])
- lock = MockAugustComponentLock(data, august_lock)
- first_lock_activity_timestamp = 1234
- lock_activity = MockActivity(
- action=ACTION_LOCK_UNLOCK,
- activity_start_timestamp=first_lock_activity_timestamp,
- activity_end_timestamp=first_lock_activity_timestamp,
- )
- lock._sync_lock_activity(lock_activity)
- assert lock.last_update_lock_status["lock_status"] == LockStatus.UNLOCKED
- assert lock.last_update_lock_status["activity_start_time_utc"] == dt.as_utc(
- datetime.datetime.fromtimestamp(first_lock_activity_timestamp)
- )
-
- # Now we do the update with an older start time to
- # make sure it ignored
- data.set_last_lock_status_update_time_utc(
- august_lock.device_id, dt.as_utc(datetime.datetime.fromtimestamp(1000))
- )
- lock_activity_timestamp = 2
- lock_activity = MockActivity(
- action=ACTION_LOCK_LOCK,
- activity_start_timestamp=lock_activity_timestamp,
- activity_end_timestamp=lock_activity_timestamp,
- )
- lock._sync_lock_activity(lock_activity)
- assert lock.last_update_lock_status["lock_status"] == LockStatus.UNLOCKED
- assert lock.last_update_lock_status["activity_start_time_utc"] == dt.as_utc(
- datetime.datetime.fromtimestamp(first_lock_activity_timestamp)
- )
-
-
-def _mocked_august_component_lock():
- data = MockAugustComponentData(last_lock_status_update_timestamp=1)
- august_lock = _mock_august_lock()
- data.set_mocked_locks([august_lock])
- return MockAugustComponentLock(data, august_lock)
+ assert lock_brokenid_name.state == STATE_UNKNOWN
diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py
new file mode 100644
index 00000000000..8c52d80c337
--- /dev/null
+++ b/tests/components/august/test_sensor.py
@@ -0,0 +1,314 @@
+"""The sensor tests for the august platform."""
+
+from homeassistant.const import STATE_UNAVAILABLE
+
+from tests.components.august.mocks import (
+ _create_august_with_devices,
+ _mock_activities_from_fixture,
+ _mock_doorbell_from_fixture,
+ _mock_doorsense_enabled_august_lock_detail,
+ _mock_lock_from_fixture,
+)
+
+
+async def test_create_doorbell(hass):
+ """Test creation of a doorbell."""
+ doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json")
+ await _create_august_with_devices(hass, [doorbell_one])
+
+ sensor_k98gidt45gul_name_battery = hass.states.get(
+ "sensor.k98gidt45gul_name_battery"
+ )
+ assert sensor_k98gidt45gul_name_battery.state == "96"
+ assert sensor_k98gidt45gul_name_battery.attributes["unit_of_measurement"] == "%"
+
+
+async def test_create_doorbell_offline(hass):
+ """Test creation of a doorbell that is offline."""
+ doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json")
+ await _create_august_with_devices(hass, [doorbell_one])
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ sensor_tmt100_name_battery = hass.states.get("sensor.tmt100_name_battery")
+ assert sensor_tmt100_name_battery.state == "81"
+ assert sensor_tmt100_name_battery.attributes["unit_of_measurement"] == "%"
+
+ entry = entity_registry.async_get("sensor.tmt100_name_battery")
+ assert entry
+ assert entry.unique_id == "tmt100_device_battery"
+
+
+async def test_create_doorbell_hardwired(hass):
+ """Test creation of a doorbell that is hardwired without a battery."""
+ doorbell_one = await _mock_doorbell_from_fixture(
+ hass, "get_doorbell.nobattery.json"
+ )
+ await _create_august_with_devices(hass, [doorbell_one])
+
+ sensor_tmt100_name_battery = hass.states.get("sensor.tmt100_name_battery")
+ assert sensor_tmt100_name_battery is None
+
+
+async def test_create_lock_with_linked_keypad(hass):
+ """Test creation of a lock with a linked keypad that both have a battery."""
+ lock_one = await _mock_lock_from_fixture(hass, "get_lock.doorsense_init.json")
+ await _create_august_with_devices(hass, [lock_one])
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ sensor_a6697750d607098bae8d6baa11ef8063_name_battery = hass.states.get(
+ "sensor.a6697750d607098bae8d6baa11ef8063_name_battery"
+ )
+ assert sensor_a6697750d607098bae8d6baa11ef8063_name_battery.state == "88"
+ assert (
+ sensor_a6697750d607098bae8d6baa11ef8063_name_battery.attributes[
+ "unit_of_measurement"
+ ]
+ == "%"
+ )
+ entry = entity_registry.async_get(
+ "sensor.a6697750d607098bae8d6baa11ef8063_name_battery"
+ )
+ assert entry
+ assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_device_battery"
+
+ sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery = hass.states.get(
+ "sensor.a6697750d607098bae8d6baa11ef8063_name_keypad_battery"
+ )
+ assert sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery.state == "60"
+ assert (
+ sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery.attributes[
+ "unit_of_measurement"
+ ]
+ == "%"
+ )
+ entry = entity_registry.async_get(
+ "sensor.a6697750d607098bae8d6baa11ef8063_name_keypad_battery"
+ )
+ assert entry
+ assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_linked_keypad_battery"
+
+
+async def test_create_lock_with_low_battery_linked_keypad(hass):
+ """Test creation of a lock with a linked keypad that both have a battery."""
+ lock_one = await _mock_lock_from_fixture(hass, "get_lock.low_keypad_battery.json")
+ await _create_august_with_devices(hass, [lock_one])
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ sensor_a6697750d607098bae8d6baa11ef8063_name_battery = hass.states.get(
+ "sensor.a6697750d607098bae8d6baa11ef8063_name_battery"
+ )
+ assert sensor_a6697750d607098bae8d6baa11ef8063_name_battery.state == "88"
+ assert (
+ sensor_a6697750d607098bae8d6baa11ef8063_name_battery.attributes[
+ "unit_of_measurement"
+ ]
+ == "%"
+ )
+ entry = entity_registry.async_get(
+ "sensor.a6697750d607098bae8d6baa11ef8063_name_battery"
+ )
+ assert entry
+ assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_device_battery"
+
+ sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery = hass.states.get(
+ "sensor.a6697750d607098bae8d6baa11ef8063_name_keypad_battery"
+ )
+ assert sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery.state == "10"
+ assert (
+ sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery.attributes[
+ "unit_of_measurement"
+ ]
+ == "%"
+ )
+ entry = entity_registry.async_get(
+ "sensor.a6697750d607098bae8d6baa11ef8063_name_keypad_battery"
+ )
+ assert entry
+ assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_linked_keypad_battery"
+
+ # No activity means it will be unavailable until someone unlocks/locks it
+ lock_operator_sensor = entity_registry.async_get(
+ "sensor.a6697750d607098bae8d6baa11ef8063_name_operator"
+ )
+ assert (
+ lock_operator_sensor.unique_id
+ == "A6697750D607098BAE8D6BAA11EF8063_lock_operator"
+ )
+ assert (
+ hass.states.get("sensor.a6697750d607098bae8d6baa11ef8063_name_operator").state
+ == STATE_UNAVAILABLE
+ )
+
+
+async def test_lock_operator_bluetooth(hass):
+ """Test operation of a lock with doorsense and bridge."""
+ lock_one = await _mock_doorsense_enabled_august_lock_detail(hass)
+
+ activities = await _mock_activities_from_fixture(
+ hass, "get_activity.lock_from_bluetooth.json"
+ )
+ await _create_august_with_devices(hass, [lock_one], activities=activities)
+
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ lock_operator_sensor = entity_registry.async_get(
+ "sensor.online_with_doorsense_name_operator"
+ )
+ assert lock_operator_sensor
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").state
+ == "Your favorite elven princess"
+ )
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").attributes[
+ "remote"
+ ]
+ is False
+ )
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").attributes[
+ "keypad"
+ ]
+ is False
+ )
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").attributes[
+ "autorelock"
+ ]
+ is False
+ )
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").attributes[
+ "method"
+ ]
+ == "mobile"
+ )
+
+
+async def test_lock_operator_keypad(hass):
+ """Test operation of a lock with doorsense and bridge."""
+ lock_one = await _mock_doorsense_enabled_august_lock_detail(hass)
+
+ activities = await _mock_activities_from_fixture(
+ hass, "get_activity.lock_from_keypad.json"
+ )
+ await _create_august_with_devices(hass, [lock_one], activities=activities)
+
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ lock_operator_sensor = entity_registry.async_get(
+ "sensor.online_with_doorsense_name_operator"
+ )
+ assert lock_operator_sensor
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").state
+ == "Your favorite elven princess"
+ )
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").attributes[
+ "remote"
+ ]
+ is False
+ )
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").attributes[
+ "keypad"
+ ]
+ is True
+ )
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").attributes[
+ "autorelock"
+ ]
+ is False
+ )
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").attributes[
+ "method"
+ ]
+ == "keypad"
+ )
+
+
+async def test_lock_operator_remote(hass):
+ """Test operation of a lock with doorsense and bridge."""
+ lock_one = await _mock_doorsense_enabled_august_lock_detail(hass)
+
+ activities = await _mock_activities_from_fixture(hass, "get_activity.lock.json")
+ await _create_august_with_devices(hass, [lock_one], activities=activities)
+
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ lock_operator_sensor = entity_registry.async_get(
+ "sensor.online_with_doorsense_name_operator"
+ )
+ assert lock_operator_sensor
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").state
+ == "Your favorite elven princess"
+ )
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").attributes[
+ "remote"
+ ]
+ is True
+ )
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").attributes[
+ "keypad"
+ ]
+ is False
+ )
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").attributes[
+ "autorelock"
+ ]
+ is False
+ )
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").attributes[
+ "method"
+ ]
+ == "remote"
+ )
+
+
+async def test_lock_operator_autorelock(hass):
+ """Test operation of a lock with doorsense and bridge."""
+ lock_one = await _mock_doorsense_enabled_august_lock_detail(hass)
+
+ activities = await _mock_activities_from_fixture(
+ hass, "get_activity.lock_from_autorelock.json"
+ )
+ await _create_august_with_devices(hass, [lock_one], activities=activities)
+
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ lock_operator_sensor = entity_registry.async_get(
+ "sensor.online_with_doorsense_name_operator"
+ )
+ assert lock_operator_sensor
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").state
+ == "Auto Relock"
+ )
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").attributes[
+ "remote"
+ ]
+ is False
+ )
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").attributes[
+ "keypad"
+ ]
+ is False
+ )
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").attributes[
+ "autorelock"
+ ]
+ is True
+ )
+ assert (
+ hass.states.get("sensor.online_with_doorsense_name_operator").attributes[
+ "method"
+ ]
+ == "autorelock"
+ )
diff --git a/tests/components/auth/test_indieauth.py b/tests/components/auth/test_indieauth.py
index ce8edae1466..b359144ab97 100644
--- a/tests/components/auth/test_indieauth.py
+++ b/tests/components/auth/test_indieauth.py
@@ -169,7 +169,8 @@ async def test_find_link_tag_max_size(hass, mock_session):
@pytest.mark.parametrize(
- "client_id", ["https://home-assistant.io/android", "https://home-assistant.io/iOS"]
+ "client_id",
+ ["https://www.home-assistant.io/android", "https://www.home-assistant.io/iOS"],
)
async def test_verify_redirect_uri_android_ios(client_id):
"""Test that we verify redirect uri correctly for Android/iOS."""
diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py
index 96d497c3dae..2c9a39c6fb6 100644
--- a/tests/components/auth/test_init.py
+++ b/tests/components/auth/test_init.py
@@ -28,7 +28,7 @@ async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client):
step = await resp.json()
resp = await client.post(
- "/auth/login_flow/{}".format(step["flow_id"]),
+ f"/auth/login_flow/{step['flow_id']}",
json={"client_id": CLIENT_ID, "username": "test-user", "password": "test-pass"},
)
@@ -71,7 +71,7 @@ async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client):
assert resp.status == 401
resp = await client.get(
- "/api/", headers={"authorization": "Bearer {}".format(tokens["access_token"])}
+ "/api/", headers={"authorization": f"Bearer {tokens['access_token']}"}
)
assert resp.status == 200
diff --git a/tests/components/auth/test_init_link_user.py b/tests/components/auth/test_init_link_user.py
index 2aa6b0d9f8d..3f0e9bce063 100644
--- a/tests/components/auth/test_init_link_user.py
+++ b/tests/components/auth/test_init_link_user.py
@@ -42,7 +42,7 @@ async def async_get_code(hass, aiohttp_client):
step = await resp.json()
resp = await client.post(
- "/auth/login_flow/{}".format(step["flow_id"]),
+ f"/auth/login_flow/{step['flow_id']}",
json={"client_id": CLIENT_ID, "username": "2nd-user", "password": "2nd-pass"},
)
@@ -67,7 +67,7 @@ async def test_link_user(hass, aiohttp_client):
resp = await client.post(
"/auth/link_user",
json={"client_id": CLIENT_ID, "code": code},
- headers={"authorization": "Bearer {}".format(info["access_token"])},
+ headers={"authorization": f"Bearer {info['access_token']}"},
)
assert resp.status == 200
@@ -84,7 +84,7 @@ async def test_link_user_invalid_client_id(hass, aiohttp_client):
resp = await client.post(
"/auth/link_user",
json={"client_id": "invalid", "code": code},
- headers={"authorization": "Bearer {}".format(info["access_token"])},
+ headers={"authorization": f"Bearer {info['access_token']}"},
)
assert resp.status == 400
@@ -100,7 +100,7 @@ async def test_link_user_invalid_code(hass, aiohttp_client):
resp = await client.post(
"/auth/link_user",
json={"client_id": CLIENT_ID, "code": "invalid"},
- headers={"authorization": "Bearer {}".format(info["access_token"])},
+ headers={"authorization": f"Bearer {info['access_token']}"},
)
assert resp.status == 400
diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py
index d7bb5448938..e6e5281d601 100644
--- a/tests/components/auth/test_login_flow.py
+++ b/tests/components/auth/test_login_flow.py
@@ -54,7 +54,7 @@ async def test_invalid_username_password(hass, aiohttp_client):
# Incorrect username
resp = await client.post(
- "/auth/login_flow/{}".format(step["flow_id"]),
+ f"/auth/login_flow/{step['flow_id']}",
json={
"client_id": CLIENT_ID,
"username": "wrong-user",
@@ -70,7 +70,7 @@ async def test_invalid_username_password(hass, aiohttp_client):
# Incorrect password
resp = await client.post(
- "/auth/login_flow/{}".format(step["flow_id"]),
+ f"/auth/login_flow/{step['flow_id']}",
json={
"client_id": CLIENT_ID,
"username": "test-user",
@@ -105,7 +105,7 @@ async def test_login_exist_user(hass, aiohttp_client):
step = await resp.json()
resp = await client.post(
- "/auth/login_flow/{}".format(step["flow_id"]),
+ f"/auth/login_flow/{step['flow_id']}",
json={"client_id": CLIENT_ID, "username": "test-user", "password": "test-pass"},
)
diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py
index 17cb8e38136..f779f022e65 100644
--- a/tests/components/automation/test_numeric_state.py
+++ b/tests/components/automation/test_numeric_state.py
@@ -3,8 +3,10 @@ from datetime import timedelta
from unittest.mock import patch
import pytest
+import voluptuous as vol
import homeassistant.components.automation as automation
+from homeassistant.components.automation import numeric_state
from homeassistant.core import Context
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
@@ -1229,3 +1231,11 @@ async def test_if_fires_on_entities_change_overlap_for_template(hass, calls):
await hass.async_block_till_done()
assert 2 == len(calls)
assert "test.entity_2 - 0:00:10" == calls[1].data["some"]
+
+
+def test_below_above():
+ """Test above cannot be above below."""
+ with pytest.raises(vol.Invalid):
+ numeric_state.TRIGGER_SCHEMA(
+ {"platform": "numeric_state", "above": 1200, "below": 1000}
+ )
diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py
index ded1520718f..2d88a5019b1 100644
--- a/tests/components/awair/test_sensor.py
+++ b/tests/components/awair/test_sensor.py
@@ -16,10 +16,14 @@ from homeassistant.components.awair.sensor import (
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import (
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_PARTS_PER_MILLION,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
STATE_UNAVAILABLE,
TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
)
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import parse_datetime, utcnow
@@ -153,7 +157,7 @@ async def test_awair_score(hass):
sensor = hass.states.get("sensor.awair_score")
assert sensor.state == "78"
assert sensor.attributes["device_class"] == DEVICE_CLASS_SCORE
- assert sensor.attributes["unit_of_measurement"] == "%"
+ assert sensor.attributes["unit_of_measurement"] == UNIT_PERCENTAGE
async def test_awair_temp(hass):
@@ -173,7 +177,7 @@ async def test_awair_humid(hass):
sensor = hass.states.get("sensor.awair_humidity")
assert sensor.state == "32.7"
assert sensor.attributes["device_class"] == DEVICE_CLASS_HUMIDITY
- assert sensor.attributes["unit_of_measurement"] == "%"
+ assert sensor.attributes["unit_of_measurement"] == UNIT_PERCENTAGE
async def test_awair_co2(hass):
@@ -183,7 +187,7 @@ async def test_awair_co2(hass):
sensor = hass.states.get("sensor.awair_co2")
assert sensor.state == "612"
assert sensor.attributes["device_class"] == DEVICE_CLASS_CARBON_DIOXIDE
- assert sensor.attributes["unit_of_measurement"] == "ppm"
+ assert sensor.attributes["unit_of_measurement"] == CONCENTRATION_PARTS_PER_MILLION
async def test_awair_voc(hass):
@@ -193,7 +197,7 @@ async def test_awair_voc(hass):
sensor = hass.states.get("sensor.awair_voc")
assert sensor.state == "1012"
assert sensor.attributes["device_class"] == DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS
- assert sensor.attributes["unit_of_measurement"] == "ppb"
+ assert sensor.attributes["unit_of_measurement"] == CONCENTRATION_PARTS_PER_BILLION
async def test_awair_dust(hass):
@@ -205,7 +209,10 @@ async def test_awair_dust(hass):
sensor = hass.states.get("sensor.awair_pm2_5")
assert sensor.state == "6.2"
assert sensor.attributes["device_class"] == DEVICE_CLASS_PM2_5
- assert sensor.attributes["unit_of_measurement"] == "µg/m3"
+ assert (
+ sensor.attributes["unit_of_measurement"]
+ == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
+ )
async def test_awair_unsupported_sensors(hass):
diff --git a/tests/components/axis/test_init.py b/tests/components/axis/test_init.py
index cf5a3b2785a..83e1337b079 100644
--- a/tests/components/axis/test_init.py
+++ b/tests/components/axis/test_init.py
@@ -38,7 +38,7 @@ async def test_setup_entry(hass):
async def test_setup_entry_fails(hass):
"""Test successful setup of entry."""
config_entry = MockConfigEntry(
- domain=axis.DOMAIN, data={axis.CONF_MAC: "0123"}, options=True, version=2
+ domain=axis.DOMAIN, data={axis.CONF_MAC: "0123"}, version=2
)
config_entry.add_to_hass(hass)
diff --git a/tests/components/bayesian/test_binary_sensor.py b/tests/components/bayesian/test_binary_sensor.py
index c8a23517ae1..fb9bc7d5e5c 100644
--- a/tests/components/bayesian/test_binary_sensor.py
+++ b/tests/components/bayesian/test_binary_sensor.py
@@ -259,3 +259,54 @@ class TestBayesianBinarySensor(unittest.TestCase):
prior = bayesian.update_probability(prior, pt, pf)
assert round(abs(0.9130434782608695 - prior), 7) == 0
+
+ def test_observed_entities(self):
+ """Test sensor on observed entities."""
+ config = {
+ "binary_sensor": {
+ "name": "Test_Binary",
+ "platform": "bayesian",
+ "observations": [
+ {
+ "platform": "state",
+ "entity_id": "sensor.test_monitored",
+ "to_state": "off",
+ "prob_given_true": 0.8,
+ "prob_given_false": 0.4,
+ },
+ {
+ "platform": "template",
+ "value_template": "{{is_state('sensor.test_monitored1','on') and is_state('sensor.test_monitored','off')}}",
+ "prob_given_true": 0.9,
+ },
+ ],
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ }
+ }
+
+ assert setup_component(self.hass, "binary_sensor", config)
+
+ self.hass.states.set("sensor.test_monitored", "on")
+ self.hass.block_till_done()
+ self.hass.states.set("sensor.test_monitored1", "off")
+ self.hass.block_till_done()
+
+ state = self.hass.states.get("binary_sensor.test_binary")
+ assert [] == state.attributes.get("occurred_observation_entities")
+
+ self.hass.states.set("sensor.test_monitored", "off")
+ self.hass.block_till_done()
+
+ state = self.hass.states.get("binary_sensor.test_binary")
+ assert ["sensor.test_monitored"] == state.attributes.get(
+ "occurred_observation_entities"
+ )
+
+ self.hass.states.set("sensor.test_monitored1", "on")
+ self.hass.block_till_done()
+
+ state = self.hass.states.get("binary_sensor.test_binary")
+ assert ["sensor.test_monitored", "sensor.test_monitored1"] == sorted(
+ state.attributes.get("occurred_observation_entities")
+ )
diff --git a/tests/components/canary/test_sensor.py b/tests/components/canary/test_sensor.py
index 7d5e829a347..6cc33ddf610 100644
--- a/tests/components/canary/test_sensor.py
+++ b/tests/components/canary/test_sensor.py
@@ -12,6 +12,7 @@ from homeassistant.components.canary.sensor import (
STATE_AIR_QUALITY_VERY_ABNORMAL,
CanarySensor,
)
+from homeassistant.const import UNIT_PERCENTAGE
from tests.common import get_test_home_assistant
from tests.components.canary.test_init import mock_device, mock_location
@@ -40,9 +41,9 @@ class TestCanarySensorSetup(unittest.TestCase):
def test_setup_sensors(self):
"""Test the sensor setup."""
- online_device_at_home = mock_device(20, "Dining Room", True, "Canary")
- offline_device_at_home = mock_device(21, "Front Yard", False, "Canary")
- online_device_at_work = mock_device(22, "Office", True, "Canary")
+ online_device_at_home = mock_device(20, "Dining Room", True, "Canary Pro")
+ offline_device_at_home = mock_device(21, "Front Yard", False, "Canary Pro")
+ online_device_at_work = mock_device(22, "Office", True, "Canary Pro")
self.hass.data[DATA_CANARY] = Mock()
self.hass.data[DATA_CANARY].locations = [
@@ -54,11 +55,11 @@ class TestCanarySensorSetup(unittest.TestCase):
canary.setup_platform(self.hass, self.config, self.add_entities, None)
- assert 6 == len(self.DEVICES)
+ assert len(self.DEVICES) == 6
def test_temperature_sensor(self):
"""Test temperature sensor with fahrenheit."""
- device = mock_device(10, "Family Room", "Canary")
+ device = mock_device(10, "Family Room", "Canary Pro")
location = mock_location("Home", False)
data = Mock()
@@ -67,14 +68,14 @@ class TestCanarySensorSetup(unittest.TestCase):
sensor = CanarySensor(data, SENSOR_TYPES[0], location, device)
sensor.update()
- assert "Home Family Room Temperature" == sensor.name
- assert "°C" == sensor.unit_of_measurement
- assert 21.12 == sensor.state
- assert "mdi:thermometer" == sensor.icon
+ assert sensor.name == "Home Family Room Temperature"
+ assert sensor.unit_of_measurement == "°C"
+ assert sensor.state == 21.12
+ assert sensor.icon == "mdi:thermometer"
def test_temperature_sensor_with_none_sensor_value(self):
"""Test temperature sensor with fahrenheit."""
- device = mock_device(10, "Family Room", "Canary")
+ device = mock_device(10, "Family Room", "Canary Pro")
location = mock_location("Home", False)
data = Mock()
@@ -87,7 +88,7 @@ class TestCanarySensorSetup(unittest.TestCase):
def test_humidity_sensor(self):
"""Test humidity sensor."""
- device = mock_device(10, "Family Room", "Canary")
+ device = mock_device(10, "Family Room", "Canary Pro")
location = mock_location("Home")
data = Mock()
@@ -96,14 +97,14 @@ class TestCanarySensorSetup(unittest.TestCase):
sensor = CanarySensor(data, SENSOR_TYPES[1], location, device)
sensor.update()
- assert "Home Family Room Humidity" == sensor.name
- assert "%" == sensor.unit_of_measurement
- assert 50.46 == sensor.state
- assert "mdi:water-percent" == sensor.icon
+ assert sensor.name == "Home Family Room Humidity"
+ assert sensor.unit_of_measurement == UNIT_PERCENTAGE
+ assert sensor.state == 50.46
+ assert sensor.icon == "mdi:water-percent"
def test_air_quality_sensor_with_very_abnormal_reading(self):
"""Test air quality sensor."""
- device = mock_device(10, "Family Room", "Canary")
+ device = mock_device(10, "Family Room", "Canary Pro")
location = mock_location("Home")
data = Mock()
@@ -112,17 +113,17 @@ class TestCanarySensorSetup(unittest.TestCase):
sensor = CanarySensor(data, SENSOR_TYPES[2], location, device)
sensor.update()
- assert "Home Family Room Air Quality" == sensor.name
+ assert sensor.name == "Home Family Room Air Quality"
assert sensor.unit_of_measurement is None
- assert 0.4 == sensor.state
- assert "mdi:weather-windy" == sensor.icon
+ assert sensor.state == 0.4
+ assert sensor.icon == "mdi:weather-windy"
air_quality = sensor.device_state_attributes[ATTR_AIR_QUALITY]
- assert STATE_AIR_QUALITY_VERY_ABNORMAL == air_quality
+ assert air_quality == STATE_AIR_QUALITY_VERY_ABNORMAL
def test_air_quality_sensor_with_abnormal_reading(self):
"""Test air quality sensor."""
- device = mock_device(10, "Family Room", "Canary")
+ device = mock_device(10, "Family Room", "Canary Pro")
location = mock_location("Home")
data = Mock()
@@ -131,17 +132,17 @@ class TestCanarySensorSetup(unittest.TestCase):
sensor = CanarySensor(data, SENSOR_TYPES[2], location, device)
sensor.update()
- assert "Home Family Room Air Quality" == sensor.name
+ assert sensor.name == "Home Family Room Air Quality"
assert sensor.unit_of_measurement is None
- assert 0.59 == sensor.state
- assert "mdi:weather-windy" == sensor.icon
+ assert sensor.state == 0.59
+ assert sensor.icon == "mdi:weather-windy"
air_quality = sensor.device_state_attributes[ATTR_AIR_QUALITY]
- assert STATE_AIR_QUALITY_ABNORMAL == air_quality
+ assert air_quality == STATE_AIR_QUALITY_ABNORMAL
def test_air_quality_sensor_with_normal_reading(self):
"""Test air quality sensor."""
- device = mock_device(10, "Family Room", "Canary")
+ device = mock_device(10, "Family Room", "Canary Pro")
location = mock_location("Home")
data = Mock()
@@ -150,17 +151,17 @@ class TestCanarySensorSetup(unittest.TestCase):
sensor = CanarySensor(data, SENSOR_TYPES[2], location, device)
sensor.update()
- assert "Home Family Room Air Quality" == sensor.name
+ assert sensor.name == "Home Family Room Air Quality"
assert sensor.unit_of_measurement is None
- assert 1.0 == sensor.state
- assert "mdi:weather-windy" == sensor.icon
+ assert sensor.state == 1.0
+ assert sensor.icon == "mdi:weather-windy"
air_quality = sensor.device_state_attributes[ATTR_AIR_QUALITY]
- assert STATE_AIR_QUALITY_NORMAL == air_quality
+ assert air_quality == STATE_AIR_QUALITY_NORMAL
def test_air_quality_sensor_with_none_sensor_value(self):
"""Test air quality sensor."""
- device = mock_device(10, "Family Room", "Canary")
+ device = mock_device(10, "Family Room", "Canary Pro")
location = mock_location("Home")
data = Mock()
@@ -183,10 +184,10 @@ class TestCanarySensorSetup(unittest.TestCase):
sensor = CanarySensor(data, SENSOR_TYPES[4], location, device)
sensor.update()
- assert "Home Family Room Battery" == sensor.name
- assert "%" == sensor.unit_of_measurement
- assert 70.46 == sensor.state
- assert "mdi:battery-70" == sensor.icon
+ assert sensor.name == "Home Family Room Battery"
+ assert sensor.unit_of_measurement == UNIT_PERCENTAGE
+ assert sensor.state == 70.46
+ assert sensor.icon == "mdi:battery-70"
def test_wifi_sensor(self):
"""Test battery sensor."""
@@ -199,7 +200,7 @@ class TestCanarySensorSetup(unittest.TestCase):
sensor = CanarySensor(data, SENSOR_TYPES[3], location, device)
sensor.update()
- assert "Home Family Room Wifi" == sensor.name
- assert "dBm" == sensor.unit_of_measurement
- assert -57 == sensor.state
- assert "mdi:wifi" == sensor.icon
+ assert sensor.name == "Home Family Room Wifi"
+ assert sensor.unit_of_measurement == "dBm"
+ assert sensor.state == -57
+ assert sensor.icon == "mdi:wifi"
diff --git a/tests/components/cert_expiry/const.py b/tests/components/cert_expiry/const.py
new file mode 100644
index 00000000000..9ddbeca61c3
--- /dev/null
+++ b/tests/components/cert_expiry/const.py
@@ -0,0 +1,3 @@
+"""Constants for cert_expiry tests."""
+PORT = 443
+HOST = "example.com"
diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py
index 71005672fdb..1b2cc175dcb 100644
--- a/tests/components/cert_expiry/test_config_flow.py
+++ b/tests/components/cert_expiry/test_config_flow.py
@@ -1,154 +1,218 @@
"""Tests for the Cert Expiry config flow."""
import socket
import ssl
-from unittest.mock import patch
-import pytest
+from asynctest import patch
from homeassistant import data_entry_flow
-from homeassistant.components.cert_expiry import config_flow
-from homeassistant.components.cert_expiry.const import DEFAULT_NAME, DEFAULT_PORT
+from homeassistant.components.cert_expiry.const import DEFAULT_PORT, DOMAIN
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
-from tests.common import MockConfigEntry, mock_coro
+from .const import HOST, PORT
-NAME = "Cert Expiry test 1 2 3"
-PORT = 443
-HOST = "example.com"
+from tests.common import MockConfigEntry
-@pytest.fixture(name="test_connect")
-def mock_controller():
- """Mock a successful _prt_in_configuration_exists."""
- with patch(
- "homeassistant.components.cert_expiry.config_flow.CertexpiryConfigFlow._test_connection",
- side_effect=lambda *_: mock_coro(True),
- ):
- yield
-
-
-def init_config_flow(hass):
- """Init a configuration flow."""
- flow = config_flow.CertexpiryConfigFlow()
- flow.hass = hass
- return flow
-
-
-async def test_user(hass, test_connect):
+async def test_user(hass):
"""Test user config."""
- flow = init_config_flow(hass)
-
- result = await flow.async_step_user()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}
+ )
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
- # tets with all provided
- result = await flow.async_step_user(
- {CONF_NAME: NAME, CONF_HOST: HOST, CONF_PORT: PORT}
- )
+ with patch(
+ "homeassistant.components.cert_expiry.config_flow.get_cert_time_to_expiry"
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={CONF_HOST: HOST, CONF_PORT: PORT}
+ )
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == NAME
+ assert result["title"] == HOST
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_PORT] == PORT
+ assert result["result"].unique_id == f"{HOST}:{PORT}"
+
+ with patch("homeassistant.components.cert_expiry.sensor.async_setup_entry"):
+ await hass.async_block_till_done()
-async def test_import(hass, test_connect):
- """Test import step."""
- flow = init_config_flow(hass)
-
- # import with only host
- result = await flow.async_step_import({CONF_HOST: HOST})
- assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == DEFAULT_NAME
- assert result["data"][CONF_HOST] == HOST
- assert result["data"][CONF_PORT] == DEFAULT_PORT
-
- # import with host and name
- result = await flow.async_step_import({CONF_HOST: HOST, CONF_NAME: NAME})
- assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == NAME
- assert result["data"][CONF_HOST] == HOST
- assert result["data"][CONF_PORT] == DEFAULT_PORT
-
- # improt with host and port
- result = await flow.async_step_import({CONF_HOST: HOST, CONF_PORT: PORT})
- assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == DEFAULT_NAME
- assert result["data"][CONF_HOST] == HOST
- assert result["data"][CONF_PORT] == PORT
-
- # import with all
- result = await flow.async_step_import(
- {CONF_HOST: HOST, CONF_PORT: PORT, CONF_NAME: NAME}
- )
- assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == NAME
- assert result["data"][CONF_HOST] == HOST
- assert result["data"][CONF_PORT] == PORT
-
-
-async def test_abort_if_already_setup(hass, test_connect):
- """Test we abort if the cert is already setup."""
- flow = init_config_flow(hass)
- MockConfigEntry(
- domain="cert_expiry",
- data={CONF_PORT: DEFAULT_PORT, CONF_NAME: NAME, CONF_HOST: HOST},
- ).add_to_hass(hass)
-
- # Should fail, same HOST and PORT (default)
- result = await flow.async_step_import(
- {CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: DEFAULT_PORT}
- )
- assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
- assert result["reason"] == "host_port_exists"
-
- # Should be the same HOST and PORT (default)
- result = await flow.async_step_user(
- {CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: DEFAULT_PORT}
+async def test_user_with_bad_cert(hass):
+ """Test user config with bad certificate."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["errors"] == {CONF_HOST: "host_port_exists"}
+ assert result["step_id"] == "user"
+
+ with patch(
+ "homeassistant.components.cert_expiry.helper.get_cert",
+ side_effect=ssl.SSLError("some error"),
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={CONF_HOST: HOST, CONF_PORT: PORT}
+ )
- # SHOULD pass, same Host diff PORT
- result = await flow.async_step_import(
- {CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: 888}
- )
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == NAME
+ assert result["title"] == HOST
+ assert result["data"][CONF_HOST] == HOST
+ assert result["data"][CONF_PORT] == PORT
+ assert result["result"].unique_id == f"{HOST}:{PORT}"
+
+ with patch("homeassistant.components.cert_expiry.sensor.async_setup_entry"):
+ await hass.async_block_till_done()
+
+
+async def test_import_host_only(hass):
+ """Test import with host only."""
+ with patch(
+ "homeassistant.components.cert_expiry.config_flow.get_cert_time_to_expiry",
+ return_value=1,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "import"}, data={CONF_HOST: HOST}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == HOST
+ assert result["data"][CONF_HOST] == HOST
+ assert result["data"][CONF_PORT] == DEFAULT_PORT
+ assert result["result"].unique_id == f"{HOST}:{DEFAULT_PORT}"
+
+ with patch("homeassistant.components.cert_expiry.sensor.async_setup_entry"):
+ await hass.async_block_till_done()
+
+
+async def test_import_host_and_port(hass):
+ """Test import with host and port."""
+ with patch(
+ "homeassistant.components.cert_expiry.config_flow.get_cert_time_to_expiry",
+ return_value=1,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": "import"},
+ data={CONF_HOST: HOST, CONF_PORT: PORT},
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == HOST
+ assert result["data"][CONF_HOST] == HOST
+ assert result["data"][CONF_PORT] == PORT
+ assert result["result"].unique_id == f"{HOST}:{PORT}"
+
+ with patch("homeassistant.components.cert_expiry.sensor.async_setup_entry"):
+ await hass.async_block_till_done()
+
+
+async def test_import_non_default_port(hass):
+ """Test import with host and non-default port."""
+ with patch(
+ "homeassistant.components.cert_expiry.config_flow.get_cert_time_to_expiry"
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "import"}, data={CONF_HOST: HOST, CONF_PORT: 888}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == f"{HOST}:888"
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_PORT] == 888
+ assert result["result"].unique_id == f"{HOST}:888"
+
+ with patch("homeassistant.components.cert_expiry.sensor.async_setup_entry"):
+ await hass.async_block_till_done()
+
+
+async def test_import_with_name(hass):
+ """Test import with name (deprecated)."""
+ with patch(
+ "homeassistant.components.cert_expiry.config_flow.get_cert_time_to_expiry",
+ return_value=1,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": "import"},
+ data={CONF_NAME: "legacy", CONF_HOST: HOST, CONF_PORT: PORT},
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == HOST
+ assert result["data"][CONF_HOST] == HOST
+ assert result["data"][CONF_PORT] == PORT
+ assert result["result"].unique_id == f"{HOST}:{PORT}"
+
+ with patch("homeassistant.components.cert_expiry.sensor.async_setup_entry"):
+ await hass.async_block_till_done()
+
+
+async def test_bad_import(hass):
+ """Test import step."""
+ with patch(
+ "homeassistant.components.cert_expiry.helper.get_cert",
+ side_effect=ConnectionRefusedError(),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "import"}, data={CONF_HOST: HOST}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "import_failed"
+
+
+async def test_abort_if_already_setup(hass):
+ """Test we abort if the cert is already setup."""
+ MockConfigEntry(
+ domain="cert_expiry",
+ data={CONF_HOST: HOST, CONF_PORT: PORT},
+ unique_id=f"{HOST}:{PORT}",
+ ).add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "import"}, data={CONF_HOST: HOST, CONF_PORT: PORT}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"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_abort_on_socket_failed(hass):
"""Test we abort of we have errors during socket creation."""
- flow = init_config_flow(hass)
-
- with patch("socket.create_connection", side_effect=socket.gaierror()):
- result = await flow.async_step_user({CONF_HOST: HOST})
- assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["errors"] == {CONF_HOST: "resolve_failed"}
-
- with patch("socket.create_connection", side_effect=socket.timeout()):
- result = await flow.async_step_user({CONF_HOST: HOST})
- assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["errors"] == {CONF_HOST: "connection_timeout"}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}
+ )
with patch(
- "socket.create_connection",
- side_effect=ssl.CertificateError(f"{HOST} doesn't match somethingelse.com"),
+ "homeassistant.components.cert_expiry.helper.get_cert",
+ side_effect=socket.gaierror(),
):
- result = await flow.async_step_user({CONF_HOST: HOST})
- assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["errors"] == {CONF_HOST: "wrong_host"}
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={CONF_HOST: HOST}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {CONF_HOST: "resolve_failed"}
with patch(
- "socket.create_connection", side_effect=ssl.CertificateError("different error")
+ "homeassistant.components.cert_expiry.helper.get_cert",
+ side_effect=socket.timeout(),
):
- result = await flow.async_step_user({CONF_HOST: HOST})
- assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["errors"] == {CONF_HOST: "certificate_error"}
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={CONF_HOST: HOST}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {CONF_HOST: "connection_timeout"}
- with patch("socket.create_connection", side_effect=ssl.SSLError()):
- result = await flow.async_step_user({CONF_HOST: HOST})
- assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["errors"] == {CONF_HOST: "certificate_error"}
+ with patch(
+ "homeassistant.components.cert_expiry.helper.get_cert",
+ side_effect=ConnectionRefusedError,
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={CONF_HOST: HOST}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {CONF_HOST: "connection_refused"}
diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py
new file mode 100644
index 00000000000..d4419b48370
--- /dev/null
+++ b/tests/components/cert_expiry/test_init.py
@@ -0,0 +1,96 @@
+"""Tests for Cert Expiry setup."""
+from datetime import timedelta
+
+from asynctest import patch
+
+from homeassistant.components.cert_expiry.const import DOMAIN
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED
+from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_START
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+
+from .const import HOST, PORT
+
+from tests.common import MockConfigEntry, async_fire_time_changed
+
+
+async def test_setup_with_config(hass):
+ """Test setup component with config."""
+ config = {
+ SENSOR_DOMAIN: [
+ {"platform": DOMAIN, CONF_HOST: HOST, CONF_PORT: PORT},
+ {"platform": DOMAIN, CONF_HOST: HOST, CONF_PORT: 888},
+ ],
+ }
+ assert await async_setup_component(hass, SENSOR_DOMAIN, config) is True
+ await hass.async_block_till_done()
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+ next_update = dt_util.utcnow() + timedelta(seconds=20)
+ async_fire_time_changed(hass, next_update)
+
+ with patch(
+ "homeassistant.components.cert_expiry.config_flow.get_cert_time_to_expiry",
+ return_value=100,
+ ), patch(
+ "homeassistant.components.cert_expiry.sensor.get_cert_time_to_expiry",
+ return_value=100,
+ ):
+ await hass.async_block_till_done()
+
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 2
+
+
+async def test_update_unique_id(hass):
+ """Test updating a config entry without a unique_id."""
+ entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT})
+ entry.add_to_hass(hass)
+
+ config_entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(config_entries) == 1
+ assert entry is config_entries[0]
+ assert not entry.unique_id
+
+ with patch(
+ "homeassistant.components.cert_expiry.sensor.get_cert_time_to_expiry",
+ return_value=100,
+ ):
+ assert await async_setup_component(hass, DOMAIN, {}) is True
+ await hass.async_block_till_done()
+
+ assert entry.state == ENTRY_STATE_LOADED
+ assert entry.unique_id == f"{HOST}:{PORT}"
+
+
+async def test_unload_config_entry(hass):
+ """Test unloading a config entry."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_HOST: HOST, CONF_PORT: PORT},
+ unique_id=f"{HOST}:{PORT}",
+ )
+ entry.add_to_hass(hass)
+
+ config_entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(config_entries) == 1
+ assert entry is config_entries[0]
+
+ with patch(
+ "homeassistant.components.cert_expiry.sensor.get_cert_time_to_expiry",
+ return_value=100,
+ ):
+ assert await async_setup_component(hass, DOMAIN, {}) is True
+ await hass.async_block_till_done()
+
+ assert entry.state == ENTRY_STATE_LOADED
+ state = hass.states.get("sensor.cert_expiry_example_com")
+ assert state.state == "100"
+ assert state.attributes.get("error") == "None"
+ assert state.attributes.get("is_valid")
+
+ await hass.config_entries.async_unload(entry.entry_id)
+
+ assert entry.state == ENTRY_STATE_NOT_LOADED
+ state = hass.states.get("sensor.cert_expiry_example_com")
+ assert state is None
diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py
new file mode 100644
index 00000000000..6594b0988e7
--- /dev/null
+++ b/tests/components/cert_expiry/test_sensors.py
@@ -0,0 +1,211 @@
+"""Tests for the Cert Expiry sensors."""
+from datetime import timedelta
+import socket
+import ssl
+
+from asynctest import patch
+
+from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE
+import homeassistant.util.dt as dt_util
+
+from .const import HOST, PORT
+
+from tests.common import MockConfigEntry, async_fire_time_changed
+
+
+async def test_async_setup_entry(hass):
+ """Test async_setup_entry."""
+ entry = MockConfigEntry(
+ domain="cert_expiry",
+ data={CONF_HOST: HOST, CONF_PORT: PORT},
+ unique_id=f"{HOST}:{PORT}",
+ )
+
+ with patch(
+ "homeassistant.components.cert_expiry.sensor.get_cert_time_to_expiry",
+ return_value=100,
+ ):
+ entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.cert_expiry_example_com")
+ assert state is not None
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "100"
+ assert state.attributes.get("error") == "None"
+ assert state.attributes.get("is_valid")
+
+
+async def test_async_setup_entry_bad_cert(hass):
+ """Test async_setup_entry with a bad/expired cert."""
+ entry = MockConfigEntry(
+ domain="cert_expiry",
+ data={CONF_HOST: HOST, CONF_PORT: PORT},
+ unique_id=f"{HOST}:{PORT}",
+ )
+
+ with patch(
+ "homeassistant.components.cert_expiry.helper.get_cert",
+ side_effect=ssl.SSLError("some error"),
+ ):
+ entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.cert_expiry_example_com")
+ assert state is not None
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "0"
+ assert state.attributes.get("error") == "some error"
+ assert not state.attributes.get("is_valid")
+
+
+async def test_async_setup_entry_host_unavailable(hass):
+ """Test async_setup_entry when host is unavailable."""
+ entry = MockConfigEntry(
+ domain="cert_expiry",
+ data={CONF_HOST: HOST, CONF_PORT: PORT},
+ unique_id=f"{HOST}:{PORT}",
+ )
+
+ with patch(
+ "homeassistant.components.cert_expiry.helper.get_cert",
+ side_effect=socket.gaierror,
+ ):
+ entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.cert_expiry_example_com")
+ assert state is None
+
+ next_update = dt_util.utcnow() + timedelta(seconds=45)
+ async_fire_time_changed(hass, next_update)
+ with patch(
+ "homeassistant.components.cert_expiry.helper.get_cert",
+ side_effect=socket.gaierror,
+ ):
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.cert_expiry_example_com")
+ assert state is None
+
+
+async def test_update_sensor(hass):
+ """Test async_update for sensor."""
+ entry = MockConfigEntry(
+ domain="cert_expiry",
+ data={CONF_HOST: HOST, CONF_PORT: PORT},
+ unique_id=f"{HOST}:{PORT}",
+ )
+
+ with patch(
+ "homeassistant.components.cert_expiry.sensor.get_cert_time_to_expiry",
+ return_value=100,
+ ):
+ entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.cert_expiry_example_com")
+ assert state is not None
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "100"
+ assert state.attributes.get("error") == "None"
+ assert state.attributes.get("is_valid")
+
+ next_update = dt_util.utcnow() + timedelta(hours=12)
+ async_fire_time_changed(hass, next_update)
+
+ with patch(
+ "homeassistant.components.cert_expiry.sensor.get_cert_time_to_expiry",
+ return_value=99,
+ ):
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.cert_expiry_example_com")
+ assert state is not None
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "99"
+ assert state.attributes.get("error") == "None"
+ assert state.attributes.get("is_valid")
+
+
+async def test_update_sensor_network_errors(hass):
+ """Test async_update for sensor."""
+ entry = MockConfigEntry(
+ domain="cert_expiry",
+ data={CONF_HOST: HOST, CONF_PORT: PORT},
+ unique_id=f"{HOST}:{PORT}",
+ )
+
+ with patch(
+ "homeassistant.components.cert_expiry.sensor.get_cert_time_to_expiry",
+ return_value=100,
+ ):
+ entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.cert_expiry_example_com")
+ assert state is not None
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "100"
+ assert state.attributes.get("error") == "None"
+ assert state.attributes.get("is_valid")
+
+ next_update = dt_util.utcnow() + timedelta(hours=12)
+ async_fire_time_changed(hass, next_update)
+
+ with patch(
+ "homeassistant.components.cert_expiry.helper.get_cert",
+ side_effect=socket.gaierror,
+ ):
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.cert_expiry_example_com")
+ assert state.state == STATE_UNAVAILABLE
+
+ next_update = dt_util.utcnow() + timedelta(hours=12)
+ async_fire_time_changed(hass, next_update)
+
+ with patch(
+ "homeassistant.components.cert_expiry.sensor.get_cert_time_to_expiry",
+ return_value=99,
+ ):
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.cert_expiry_example_com")
+ assert state is not None
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "99"
+ assert state.attributes.get("error") == "None"
+ assert state.attributes.get("is_valid")
+
+ next_update = dt_util.utcnow() + timedelta(hours=12)
+ async_fire_time_changed(hass, next_update)
+
+ with patch(
+ "homeassistant.components.cert_expiry.helper.get_cert",
+ side_effect=ssl.SSLError("something bad"),
+ ):
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.cert_expiry_example_com")
+ assert state is not None
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "0"
+ assert state.attributes.get("error") == "something bad"
+ assert not state.attributes.get("is_valid")
+
+ next_update = dt_util.utcnow() + timedelta(hours=12)
+ async_fire_time_changed(hass, next_update)
+
+ with patch(
+ "homeassistant.components.cert_expiry.helper.get_cert", side_effect=Exception()
+ ):
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.cert_expiry_example_com")
+ assert state.state == STATE_UNAVAILABLE
diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py
index dbc936b9216..8bfa6185e9b 100644
--- a/tests/components/cloud/test_http_api.py
+++ b/tests/components/cloud/test_http_api.py
@@ -1,8 +1,9 @@
"""Tests for the HTTP API for the cloud component."""
import asyncio
from ipaddress import ip_network
-from unittest.mock import MagicMock, Mock, patch
+from unittest.mock import MagicMock, Mock
+from asynctest import patch
from hass_nabucasa import thingtalk
from hass_nabucasa.auth import Unauthenticated, UnknownError
from hass_nabucasa.const import STATE_CONNECTED
@@ -131,7 +132,7 @@ async def test_login_view_random_exception(cloud_client):
async def test_login_view_invalid_json(cloud_client):
"""Try logging in with invalid JSON."""
- with patch("hass_nabucasa.auth.CognitoAuth.login") as mock_login:
+ with patch("hass_nabucasa.auth.CognitoAuth.async_login") as mock_login:
req = await cloud_client.post("/api/cloud/login", data="Not JSON")
assert req.status == 400
assert len(mock_login.mock_calls) == 0
@@ -139,7 +140,7 @@ async def test_login_view_invalid_json(cloud_client):
async def test_login_view_invalid_schema(cloud_client):
"""Try logging in with invalid schema."""
- with patch("hass_nabucasa.auth.CognitoAuth.login") as mock_login:
+ with patch("hass_nabucasa.auth.CognitoAuth.async_login") as mock_login:
req = await cloud_client.post("/api/cloud/login", json={"invalid": "schema"})
assert req.status == 400
assert len(mock_login.mock_calls) == 0
@@ -148,7 +149,7 @@ async def test_login_view_invalid_schema(cloud_client):
async def test_login_view_request_timeout(cloud_client):
"""Test request timeout while trying to log in."""
with patch(
- "hass_nabucasa.auth.CognitoAuth.login", side_effect=asyncio.TimeoutError
+ "hass_nabucasa.auth.CognitoAuth.async_login", side_effect=asyncio.TimeoutError
):
req = await cloud_client.post(
"/api/cloud/login", json={"email": "my_username", "password": "my_password"}
@@ -159,7 +160,9 @@ async def test_login_view_request_timeout(cloud_client):
async def test_login_view_invalid_credentials(cloud_client):
"""Test logging in with invalid credentials."""
- with patch("hass_nabucasa.auth.CognitoAuth.login", side_effect=Unauthenticated):
+ with patch(
+ "hass_nabucasa.auth.CognitoAuth.async_login", side_effect=Unauthenticated
+ ):
req = await cloud_client.post(
"/api/cloud/login", json={"email": "my_username", "password": "my_password"}
)
@@ -169,7 +172,7 @@ async def test_login_view_invalid_credentials(cloud_client):
async def test_login_view_unknown_error(cloud_client):
"""Test unknown error while logging in."""
- with patch("hass_nabucasa.auth.CognitoAuth.login", side_effect=UnknownError):
+ with patch("hass_nabucasa.auth.CognitoAuth.async_login", side_effect=UnknownError):
req = await cloud_client.post(
"/api/cloud/login", json={"email": "my_username", "password": "my_password"}
)
@@ -382,7 +385,7 @@ async def test_websocket_subscription_reconnect(
client = await hass_ws_client(hass)
with patch(
- "hass_nabucasa.auth.CognitoAuth.renew_access_token"
+ "hass_nabucasa.auth.CognitoAuth.async_renew_access_token"
) as mock_renew, patch("hass_nabucasa.iot.CloudIoT.connect") as mock_connect:
await client.send_json({"id": 5, "type": "cloud/subscription"})
response = await client.receive_json()
@@ -401,7 +404,7 @@ async def test_websocket_subscription_no_reconnect_if_connected(
client = await hass_ws_client(hass)
with patch(
- "hass_nabucasa.auth.CognitoAuth.renew_access_token"
+ "hass_nabucasa.auth.CognitoAuth.async_renew_access_token"
) as mock_renew, patch("hass_nabucasa.iot.CloudIoT.connect") as mock_connect:
await client.send_json({"id": 5, "type": "cloud/subscription"})
response = await client.receive_json()
@@ -419,7 +422,7 @@ async def test_websocket_subscription_no_reconnect_if_expired(
client = await hass_ws_client(hass)
with patch(
- "hass_nabucasa.auth.CognitoAuth.renew_access_token"
+ "hass_nabucasa.auth.CognitoAuth.async_renew_access_token"
) as mock_renew, patch("hass_nabucasa.iot.CloudIoT.connect") as mock_connect:
await client.send_json({"id": 5, "type": "cloud/subscription"})
response = await client.receive_json()
diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py
index 8fe7e8fdbe4..2a696624e0c 100644
--- a/tests/components/config/test_entity_registry.py
+++ b/tests/components/config/test_entity_registry.py
@@ -42,8 +42,6 @@ async def test_list_entities(hass, client):
"entity_id": "test_domain.name",
"name": "Hello World",
"icon": None,
- "original_name": None,
- "original_icon": None,
"platform": "test_platform",
},
{
@@ -53,8 +51,6 @@ async def test_list_entities(hass, client):
"entity_id": "test_domain.no_name",
"name": None,
"icon": None,
- "original_name": None,
- "original_icon": None,
"platform": "test_platform",
},
]
@@ -94,6 +90,8 @@ async def test_get_entity(hass, client):
"icon": None,
"original_name": None,
"original_icon": None,
+ "capabilities": None,
+ "unique_id": "1234",
}
await client.send_json(
@@ -115,6 +113,8 @@ async def test_get_entity(hass, client):
"icon": None,
"original_name": None,
"original_icon": None,
+ "capabilities": None,
+ "unique_id": "6789",
}
@@ -165,6 +165,8 @@ async def test_update_entity(hass, client):
"icon": "icon:after update",
"original_name": None,
"original_icon": None,
+ "capabilities": None,
+ "unique_id": "1234",
}
state = hass.states.get("test_domain.world")
@@ -208,6 +210,8 @@ async def test_update_entity(hass, client):
"icon": "icon:after update",
"original_name": None,
"original_icon": None,
+ "capabilities": None,
+ "unique_id": "1234",
}
@@ -254,6 +258,8 @@ async def test_update_entity_no_changes(hass, client):
"icon": None,
"original_name": None,
"original_icon": None,
+ "capabilities": None,
+ "unique_id": "1234",
}
state = hass.states.get("test_domain.world")
@@ -329,6 +335,8 @@ async def test_update_entity_id(hass, client):
"icon": None,
"original_name": None,
"original_icon": None,
+ "capabilities": None,
+ "unique_id": "1234",
}
assert hass.states.get("test_domain.world") is None
diff --git a/tests/components/coronavirus/conftest.py b/tests/components/coronavirus/conftest.py
new file mode 100644
index 00000000000..45d2a00e69d
--- /dev/null
+++ b/tests/components/coronavirus/conftest.py
@@ -0,0 +1,17 @@
+"""Test helpers."""
+
+from asynctest import Mock, patch
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def mock_cases():
+ """Mock coronavirus cases."""
+ with patch(
+ "coronavirus.get_cases",
+ return_value=[
+ Mock(country="Netherlands", confirmed=10, recovered=8, deaths=1, current=1),
+ Mock(country="Germany", confirmed=1, recovered=0, deaths=0, current=0),
+ ],
+ ) as mock_get_cases:
+ yield mock_get_cases
diff --git a/tests/components/coronavirus/test_config_flow.py b/tests/components/coronavirus/test_config_flow.py
index ef04d0df07a..b7af2e343b2 100644
--- a/tests/components/coronavirus/test_config_flow.py
+++ b/tests/components/coronavirus/test_config_flow.py
@@ -1,6 +1,4 @@
"""Test the Coronavirus config flow."""
-from asynctest import patch
-
from homeassistant import config_entries, setup
from homeassistant.components.coronavirus.const import DOMAIN, OPTION_WORLDWIDE
@@ -14,14 +12,9 @@ async def test_form(hass):
assert result["type"] == "form"
assert result["errors"] == {}
- with patch("coronavirus.get_cases", return_value=[],), patch(
- "homeassistant.components.coronavirus.async_setup", return_value=True
- ) as mock_setup, patch(
- "homeassistant.components.coronavirus.async_setup_entry", return_value=True,
- ) as mock_setup_entry:
- result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"country": OPTION_WORLDWIDE},
- )
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"country": OPTION_WORLDWIDE},
+ )
assert result2["type"] == "create_entry"
assert result2["title"] == "Worldwide"
assert result2["result"].unique_id == OPTION_WORLDWIDE
@@ -29,5 +22,4 @@ async def test_form(hass):
"country": OPTION_WORLDWIDE,
}
await hass.async_block_till_done()
- assert len(mock_setup.mock_calls) == 1
- assert len(mock_setup_entry.mock_calls) == 1
+ assert len(hass.states.async_all()) == 4
diff --git a/tests/components/coronavirus/test_init.py b/tests/components/coronavirus/test_init.py
index 57293635570..483fabab9f9 100644
--- a/tests/components/coronavirus/test_init.py
+++ b/tests/components/coronavirus/test_init.py
@@ -1,6 +1,4 @@
"""Test init of Coronavirus integration."""
-from asynctest import Mock, patch
-
from homeassistant.components.coronavirus.const import DOMAIN, OPTION_WORLDWIDE
from homeassistant.helpers import entity_registry
from homeassistant.setup import async_setup_component
@@ -33,15 +31,8 @@ async def test_migration(hass):
),
},
)
- with patch(
- "coronavirus.get_cases",
- return_value=[
- Mock(country="Netherlands", confirmed=10, recovered=8, deaths=1, current=1),
- Mock(country="Germany", confirmed=1, recovered=0, deaths=0, current=0),
- ],
- ):
- assert await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
ent_reg = await entity_registry.async_get_registry(hass)
diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py
index f5ff825e7fb..d6a41af6deb 100644
--- a/tests/components/counter/test_init.py
+++ b/tests/components/counter/test_init.py
@@ -2,8 +2,13 @@
# pylint: disable=protected-access
import logging
+import pytest
+
from homeassistant.components.counter import (
+ ATTR_EDITABLE,
ATTR_INITIAL,
+ ATTR_MAXIMUM,
+ ATTR_MINIMUM,
ATTR_STEP,
CONF_ICON,
CONF_INITIAL,
@@ -14,8 +19,9 @@ from homeassistant.components.counter import (
DEFAULT_STEP,
DOMAIN,
)
-from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_ICON
+from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_NAME
from homeassistant.core import Context, CoreState, State
+from homeassistant.helpers import entity_registry
from homeassistant.setup import async_setup_component
from tests.common import mock_restore_cache
@@ -28,6 +34,42 @@ from tests.components.counter.common import (
_LOGGER = logging.getLogger(__name__)
+@pytest.fixture
+def storage_setup(hass, hass_storage):
+ """Storage setup."""
+
+ async def _storage(items=None, config=None):
+ if items is None:
+ hass_storage[DOMAIN] = {
+ "key": DOMAIN,
+ "version": 1,
+ "data": {
+ "items": [
+ {
+ "id": "from_storage",
+ "initial": 10,
+ "name": "from storage",
+ "maximum": 100,
+ "minimum": 3,
+ "step": 2,
+ "restore": False,
+ }
+ ]
+ },
+ }
+ else:
+ hass_storage[DOMAIN] = {
+ "key": DOMAIN,
+ "version": 1,
+ "data": {"items": items},
+ }
+ if config is None:
+ config = {DOMAIN: {}}
+ return await async_setup_component(hass, DOMAIN, config)
+
+ return _storage
+
+
async def test_config(hass):
"""Test config."""
invalid_configs = [None, 1, {}, {"name with space": None}]
@@ -452,3 +494,209 @@ async def test_configure(hass, hass_admin_user):
assert 0 == state.attributes.get("minimum")
assert 9 == state.attributes.get("maximum")
assert 6 == state.attributes.get("initial")
+
+
+async def test_load_from_storage(hass, storage_setup):
+ """Test set up from storage."""
+ assert await storage_setup()
+ state = hass.states.get(f"{DOMAIN}.from_storage")
+ assert int(state.state) == 10
+ assert state.attributes.get(ATTR_FRIENDLY_NAME) == "from storage"
+ assert state.attributes.get(ATTR_EDITABLE)
+
+
+async def test_editable_state_attribute(hass, storage_setup):
+ """Test editable attribute."""
+ assert await storage_setup(
+ config={
+ DOMAIN: {
+ "from_yaml": {
+ "minimum": 1,
+ "maximum": 10,
+ "initial": 5,
+ "step": 1,
+ "restore": False,
+ }
+ }
+ }
+ )
+
+ state = hass.states.get(f"{DOMAIN}.from_storage")
+ assert int(state.state) == 10
+ assert state.attributes[ATTR_FRIENDLY_NAME] == "from storage"
+ assert state.attributes[ATTR_EDITABLE] is True
+
+ state = hass.states.get(f"{DOMAIN}.from_yaml")
+ assert int(state.state) == 5
+ assert state.attributes[ATTR_EDITABLE] is False
+
+
+async def test_ws_list(hass, hass_ws_client, storage_setup):
+ """Test listing via WS."""
+ assert await storage_setup(
+ config={
+ DOMAIN: {
+ "from_yaml": {
+ "minimum": 1,
+ "maximum": 10,
+ "initial": 5,
+ "step": 1,
+ "restore": False,
+ }
+ }
+ }
+ )
+
+ client = await hass_ws_client(hass)
+
+ await client.send_json({"id": 6, "type": f"{DOMAIN}/list"})
+ resp = await client.receive_json()
+ assert resp["success"]
+
+ storage_ent = "from_storage"
+ yaml_ent = "from_yaml"
+ result = {item["id"]: item for item in resp["result"]}
+
+ assert len(result) == 1
+ assert storage_ent in result
+ assert yaml_ent not in result
+ assert result[storage_ent][ATTR_NAME] == "from storage"
+
+
+async def test_ws_delete(hass, hass_ws_client, storage_setup):
+ """Test WS delete cleans up entity registry."""
+ assert await storage_setup()
+
+ input_id = "from_storage"
+ input_entity_id = f"{DOMAIN}.{input_id}"
+ ent_reg = await entity_registry.async_get_registry(hass)
+
+ state = hass.states.get(input_entity_id)
+ assert state is not None
+ assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None
+
+ client = await hass_ws_client(hass)
+
+ await client.send_json(
+ {"id": 6, "type": f"{DOMAIN}/delete", f"{DOMAIN}_id": f"{input_id}"}
+ )
+ resp = await client.receive_json()
+ assert resp["success"]
+
+ state = hass.states.get(input_entity_id)
+ assert state is None
+ assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None
+
+
+async def test_update_min_max(hass, hass_ws_client, storage_setup):
+ """Test updating min/max updates the state."""
+
+ items = [
+ {
+ "id": "from_storage",
+ "initial": 15,
+ "name": "from storage",
+ "maximum": 100,
+ "minimum": 10,
+ "step": 3,
+ "restore": True,
+ }
+ ]
+ assert await storage_setup(items)
+
+ input_id = "from_storage"
+ input_entity_id = f"{DOMAIN}.{input_id}"
+ ent_reg = await entity_registry.async_get_registry(hass)
+
+ state = hass.states.get(input_entity_id)
+ assert state is not None
+ assert int(state.state) == 15
+ assert state.attributes[ATTR_MAXIMUM] == 100
+ assert state.attributes[ATTR_MINIMUM] == 10
+ assert state.attributes[ATTR_STEP] == 3
+ assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None
+
+ client = await hass_ws_client(hass)
+
+ await client.send_json(
+ {
+ "id": 6,
+ "type": f"{DOMAIN}/update",
+ f"{DOMAIN}_id": f"{input_id}",
+ "minimum": 19,
+ }
+ )
+ resp = await client.receive_json()
+ assert resp["success"]
+
+ state = hass.states.get(input_entity_id)
+ assert int(state.state) == 19
+ assert state.attributes[ATTR_MINIMUM] == 19
+ assert state.attributes[ATTR_MAXIMUM] == 100
+ assert state.attributes[ATTR_STEP] == 3
+
+ await client.send_json(
+ {
+ "id": 7,
+ "type": f"{DOMAIN}/update",
+ f"{DOMAIN}_id": f"{input_id}",
+ "maximum": 5,
+ "minimum": 2,
+ "step": 5,
+ }
+ )
+ resp = await client.receive_json()
+ assert resp["success"]
+
+ state = hass.states.get(input_entity_id)
+ assert int(state.state) == 5
+ assert state.attributes[ATTR_MINIMUM] == 2
+ assert state.attributes[ATTR_MAXIMUM] == 5
+ assert state.attributes[ATTR_STEP] == 5
+
+ await client.send_json(
+ {
+ "id": 8,
+ "type": f"{DOMAIN}/update",
+ f"{DOMAIN}_id": f"{input_id}",
+ "maximum": None,
+ "minimum": None,
+ "step": 6,
+ }
+ )
+ resp = await client.receive_json()
+ assert resp["success"]
+
+ state = hass.states.get(input_entity_id)
+ assert int(state.state) == 5
+ assert ATTR_MINIMUM not in state.attributes
+ assert ATTR_MAXIMUM not in state.attributes
+ assert state.attributes[ATTR_STEP] == 6
+
+
+async def test_create(hass, hass_ws_client, storage_setup):
+ """Test creating counter using WS."""
+
+ items = []
+
+ assert await storage_setup(items)
+
+ counter_id = "new_counter"
+ input_entity_id = f"{DOMAIN}.{counter_id}"
+ ent_reg = await entity_registry.async_get_registry(hass)
+
+ state = hass.states.get(input_entity_id)
+ assert state is None
+ assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, counter_id) is None
+
+ client = await hass_ws_client(hass)
+
+ await client.send_json({"id": 6, "type": f"{DOMAIN}/create", "name": "new counter"})
+ resp = await client.receive_json()
+ assert resp["success"]
+
+ state = hass.states.get(input_entity_id)
+ assert int(state.state) == 0
+ assert ATTR_MINIMUM not in state.attributes
+ assert ATTR_MAXIMUM not in state.attributes
+ assert state.attributes[ATTR_STEP] == 1
diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py
new file mode 100644
index 00000000000..e70c18621f4
--- /dev/null
+++ b/tests/components/cover/test_device_action.py
@@ -0,0 +1,454 @@
+"""The tests for Cover device actions."""
+import pytest
+
+import homeassistant.components.automation as automation
+from homeassistant.components.cover import DOMAIN
+from homeassistant.const import CONF_PLATFORM
+from homeassistant.helpers import device_registry
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ MockConfigEntry,
+ assert_lists_same,
+ async_get_device_automation_capabilities,
+ 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)
+
+
+async def test_get_actions(hass, device_reg, entity_reg):
+ """Test we get the expected actions from a cover."""
+ platform = getattr(hass.components, f"test.{DOMAIN}")
+ platform.init()
+ ent = platform.ENTITIES[0]
+
+ 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", ent.unique_id, device_id=device_entry.id
+ )
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+
+ expected_actions = [
+ {
+ "domain": DOMAIN,
+ "type": "open",
+ "device_id": device_entry.id,
+ "entity_id": ent.entity_id,
+ },
+ {
+ "domain": DOMAIN,
+ "type": "close",
+ "device_id": device_entry.id,
+ "entity_id": ent.entity_id,
+ },
+ ]
+ actions = await async_get_device_automations(hass, "action", device_entry.id)
+ assert_lists_same(actions, expected_actions)
+
+
+async def test_get_actions_tilt(hass, device_reg, entity_reg):
+ """Test we get the expected actions from a cover."""
+ platform = getattr(hass.components, f"test.{DOMAIN}")
+ platform.init()
+ ent = platform.ENTITIES[3]
+
+ 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", ent.unique_id, device_id=device_entry.id
+ )
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+
+ expected_actions = [
+ {
+ "domain": DOMAIN,
+ "type": "open",
+ "device_id": device_entry.id,
+ "entity_id": ent.entity_id,
+ },
+ {
+ "domain": DOMAIN,
+ "type": "close",
+ "device_id": device_entry.id,
+ "entity_id": ent.entity_id,
+ },
+ {
+ "domain": DOMAIN,
+ "type": "open_tilt",
+ "device_id": device_entry.id,
+ "entity_id": ent.entity_id,
+ },
+ {
+ "domain": DOMAIN,
+ "type": "close_tilt",
+ "device_id": device_entry.id,
+ "entity_id": ent.entity_id,
+ },
+ ]
+ actions = await async_get_device_automations(hass, "action", device_entry.id)
+ assert_lists_same(actions, expected_actions)
+
+
+async def test_get_actions_set_pos(hass, device_reg, entity_reg):
+ """Test we get the expected actions from a cover."""
+ platform = getattr(hass.components, f"test.{DOMAIN}")
+ platform.init()
+ ent = platform.ENTITIES[1]
+
+ 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", ent.unique_id, device_id=device_entry.id
+ )
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+
+ expected_actions = [
+ {
+ "domain": DOMAIN,
+ "type": "set_position",
+ "device_id": device_entry.id,
+ "entity_id": ent.entity_id,
+ },
+ ]
+ actions = await async_get_device_automations(hass, "action", device_entry.id)
+ assert_lists_same(actions, expected_actions)
+
+
+async def test_get_actions_set_tilt_pos(hass, device_reg, entity_reg):
+ """Test we get the expected actions from a cover."""
+ platform = getattr(hass.components, f"test.{DOMAIN}")
+ platform.init()
+ ent = platform.ENTITIES[2]
+
+ 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", ent.unique_id, device_id=device_entry.id
+ )
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+
+ expected_actions = [
+ {
+ "domain": DOMAIN,
+ "type": "open",
+ "device_id": device_entry.id,
+ "entity_id": ent.entity_id,
+ },
+ {
+ "domain": DOMAIN,
+ "type": "close",
+ "device_id": device_entry.id,
+ "entity_id": ent.entity_id,
+ },
+ {
+ "domain": DOMAIN,
+ "type": "set_tilt_position",
+ "device_id": device_entry.id,
+ "entity_id": ent.entity_id,
+ },
+ ]
+ actions = await async_get_device_automations(hass, "action", device_entry.id)
+ assert_lists_same(actions, expected_actions)
+
+
+async def test_get_action_capabilities(hass, device_reg, entity_reg):
+ """Test we get the expected capabilities from a cover action."""
+ platform = getattr(hass.components, f"test.{DOMAIN}")
+ platform.init()
+ ent = platform.ENTITIES[0]
+
+ 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", ent.unique_id, device_id=device_entry.id
+ )
+
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+
+ actions = await async_get_device_automations(hass, "action", device_entry.id)
+ assert len(actions) == 2 # open, close
+ for action in actions:
+ capabilities = await async_get_device_automation_capabilities(
+ hass, "action", action
+ )
+ assert capabilities == {"extra_fields": []}
+
+
+async def test_get_action_capabilities_set_pos(hass, device_reg, entity_reg):
+ """Test we get the expected capabilities from a cover action."""
+ platform = getattr(hass.components, f"test.{DOMAIN}")
+ platform.init()
+ ent = platform.ENTITIES[1]
+
+ 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", ent.unique_id, device_id=device_entry.id
+ )
+
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+
+ expected_capabilities = {
+ "extra_fields": [
+ {
+ "name": "position",
+ "optional": True,
+ "type": "integer",
+ "default": 0,
+ "valueMax": 100,
+ "valueMin": 0,
+ }
+ ]
+ }
+ actions = await async_get_device_automations(hass, "action", device_entry.id)
+ assert len(actions) == 1 # set_position
+ for action in actions:
+ capabilities = await async_get_device_automation_capabilities(
+ hass, "action", action
+ )
+ if action["type"] == "set_position":
+ assert capabilities == expected_capabilities
+ else:
+ assert capabilities == {"extra_fields": []}
+
+
+async def test_get_action_capabilities_set_tilt_pos(hass, device_reg, entity_reg):
+ """Test we get the expected capabilities from a cover action."""
+ platform = getattr(hass.components, f"test.{DOMAIN}")
+ platform.init()
+ ent = platform.ENTITIES[2]
+
+ 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", ent.unique_id, device_id=device_entry.id
+ )
+
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+
+ expected_capabilities = {
+ "extra_fields": [
+ {
+ "name": "position",
+ "optional": True,
+ "type": "integer",
+ "default": 0,
+ "valueMax": 100,
+ "valueMin": 0,
+ }
+ ]
+ }
+ actions = await async_get_device_automations(hass, "action", device_entry.id)
+ assert len(actions) == 3 # open, close, set_tilt_position
+ for action in actions:
+ capabilities = await async_get_device_automation_capabilities(
+ hass, "action", action
+ )
+ if action["type"] == "set_tilt_position":
+ assert capabilities == expected_capabilities
+ else:
+ assert capabilities == {"extra_fields": []}
+
+
+async def test_action(hass):
+ """Test for cover actions."""
+ platform = getattr(hass.components, f"test.{DOMAIN}")
+ platform.init()
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {"platform": "event", "event_type": "test_event_open"},
+ "action": {
+ "domain": DOMAIN,
+ "device_id": "abcdefgh",
+ "entity_id": "cover.entity",
+ "type": "open",
+ },
+ },
+ {
+ "trigger": {"platform": "event", "event_type": "test_event_close"},
+ "action": {
+ "domain": DOMAIN,
+ "device_id": "abcdefgh",
+ "entity_id": "cover.entity",
+ "type": "close",
+ },
+ },
+ ]
+ },
+ )
+
+ open_calls = async_mock_service(hass, "cover", "open_cover")
+ close_calls = async_mock_service(hass, "cover", "close_cover")
+
+ hass.bus.async_fire("test_event_open")
+ await hass.async_block_till_done()
+ assert len(open_calls) == 1
+ assert len(close_calls) == 0
+
+ hass.bus.async_fire("test_event_close")
+ await hass.async_block_till_done()
+ assert len(open_calls) == 1
+ assert len(close_calls) == 1
+
+ hass.bus.async_fire("test_event_stop")
+ await hass.async_block_till_done()
+ assert len(open_calls) == 1
+ assert len(close_calls) == 1
+
+
+async def test_action_tilt(hass):
+ """Test for cover tilt actions."""
+ platform = getattr(hass.components, f"test.{DOMAIN}")
+ platform.init()
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {"platform": "event", "event_type": "test_event_open"},
+ "action": {
+ "domain": DOMAIN,
+ "device_id": "abcdefgh",
+ "entity_id": "cover.entity",
+ "type": "open_tilt",
+ },
+ },
+ {
+ "trigger": {"platform": "event", "event_type": "test_event_close"},
+ "action": {
+ "domain": DOMAIN,
+ "device_id": "abcdefgh",
+ "entity_id": "cover.entity",
+ "type": "close_tilt",
+ },
+ },
+ ]
+ },
+ )
+
+ open_calls = async_mock_service(hass, "cover", "open_cover_tilt")
+ close_calls = async_mock_service(hass, "cover", "close_cover_tilt")
+
+ hass.bus.async_fire("test_event_open")
+ await hass.async_block_till_done()
+ assert len(open_calls) == 1
+ assert len(close_calls) == 0
+
+ hass.bus.async_fire("test_event_close")
+ await hass.async_block_till_done()
+ assert len(open_calls) == 1
+ assert len(close_calls) == 1
+
+ hass.bus.async_fire("test_event_stop")
+ await hass.async_block_till_done()
+ assert len(open_calls) == 1
+ assert len(close_calls) == 1
+
+
+async def test_action_set_position(hass):
+ """Test for cover set position actions."""
+ platform = getattr(hass.components, f"test.{DOMAIN}")
+ platform.init()
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {
+ "platform": "event",
+ "event_type": "test_event_set_pos",
+ },
+ "action": {
+ "domain": DOMAIN,
+ "device_id": "abcdefgh",
+ "entity_id": "cover.entity",
+ "type": "set_position",
+ "position": 25,
+ },
+ },
+ {
+ "trigger": {
+ "platform": "event",
+ "event_type": "test_event_set_tilt_pos",
+ },
+ "action": {
+ "domain": DOMAIN,
+ "device_id": "abcdefgh",
+ "entity_id": "cover.entity",
+ "type": "set_tilt_position",
+ "position": 75,
+ },
+ },
+ ]
+ },
+ )
+
+ cover_pos_calls = async_mock_service(hass, "cover", "set_cover_position")
+ tilt_pos_calls = async_mock_service(hass, "cover", "set_cover_tilt_position")
+
+ hass.bus.async_fire("test_event_set_pos")
+ await hass.async_block_till_done()
+ assert len(cover_pos_calls) == 1
+ assert cover_pos_calls[0].data["position"] == 25
+ assert len(tilt_pos_calls) == 0
+
+ hass.bus.async_fire("test_event_set_tilt_pos")
+ await hass.async_block_till_done()
+ assert len(cover_pos_calls) == 1
+ assert len(tilt_pos_calls) == 1
+ assert tilt_pos_calls[0].data["tilt_position"] == 75
diff --git a/tests/components/demo/test_notify.py b/tests/components/demo/test_notify.py
index 30fb49be47d..e30d65112e8 100644
--- a/tests/components/demo/test_notify.py
+++ b/tests/components/demo/test_notify.py
@@ -8,7 +8,7 @@ import voluptuous as vol
import homeassistant.components.demo.notify as demo
import homeassistant.components.notify as notify
from homeassistant.core import callback
-from homeassistant.helpers import discovery, script
+from homeassistant.helpers import discovery
from homeassistant.setup import setup_component
from tests.common import assert_setup_component, get_test_home_assistant
@@ -121,7 +121,7 @@ class TestNotifyDemo(unittest.TestCase):
def test_calling_notify_from_script_loaded_from_yaml_without_title(self):
"""Test if we can call a notify from a script."""
self._setup_notify()
- conf = {
+ step = {
"service": "notify.notify",
"data": {
"data": {
@@ -130,8 +130,8 @@ class TestNotifyDemo(unittest.TestCase):
},
"data_template": {"message": "Test 123 {{ 2 + 2 }}\n"},
}
-
- script.call_from_config(self.hass, conf)
+ setup_component(self.hass, "script", {"script": {"test": {"sequence": step}}})
+ self.hass.services.call("script", "test")
self.hass.block_till_done()
assert len(self.events) == 1
assert {
@@ -144,7 +144,7 @@ class TestNotifyDemo(unittest.TestCase):
def test_calling_notify_from_script_loaded_from_yaml_with_title(self):
"""Test if we can call a notify from a script."""
self._setup_notify()
- conf = {
+ step = {
"service": "notify.notify",
"data": {
"data": {
@@ -153,8 +153,8 @@ class TestNotifyDemo(unittest.TestCase):
},
"data_template": {"message": "Test 123 {{ 2 + 2 }}\n", "title": "Test"},
}
-
- script.call_from_config(self.hass, conf)
+ setup_component(self.hass, "script", {"script": {"test": {"sequence": step}}})
+ self.hass.services.call("script", "test")
self.hass.block_till_done()
assert len(self.events) == 1
assert {
diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py
index 9a26d2de5ce..dc160b283ad 100644
--- a/tests/components/derivative/test_sensor.py
+++ b/tests/components/derivative/test_sensor.py
@@ -2,6 +2,7 @@
from datetime import timedelta
from unittest.mock import patch
+from homeassistant.const import TIME_HOURS, TIME_MINUTES, TIME_SECONDS
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
@@ -79,7 +80,7 @@ async def test_dataSet1(hass):
"""Test derivative sensor state."""
await setup_tests(
hass,
- {"unit_time": "s"},
+ {"unit_time": TIME_SECONDS},
times=[20, 30, 40, 50],
values=[10, 30, 5, 0],
expected_state=-0.5,
@@ -89,30 +90,46 @@ async def test_dataSet1(hass):
async def test_dataSet2(hass):
"""Test derivative sensor state."""
await setup_tests(
- hass, {"unit_time": "s"}, times=[20, 30], values=[5, 0], expected_state=-0.5
+ hass,
+ {"unit_time": TIME_SECONDS},
+ times=[20, 30],
+ values=[5, 0],
+ expected_state=-0.5,
)
async def test_dataSet3(hass):
"""Test derivative sensor state."""
state = await setup_tests(
- hass, {"unit_time": "s"}, times=[20, 30], values=[5, 10], expected_state=0.5
+ hass,
+ {"unit_time": TIME_SECONDS},
+ times=[20, 30],
+ values=[5, 10],
+ expected_state=0.5,
)
- assert state.attributes.get("unit_of_measurement") == "/s"
+ assert state.attributes.get("unit_of_measurement") == f"/{TIME_SECONDS}"
async def test_dataSet4(hass):
"""Test derivative sensor state."""
await setup_tests(
- hass, {"unit_time": "s"}, times=[20, 30], values=[5, 5], expected_state=0
+ hass,
+ {"unit_time": TIME_SECONDS},
+ times=[20, 30],
+ values=[5, 5],
+ expected_state=0,
)
async def test_dataSet5(hass):
"""Test derivative sensor state."""
await setup_tests(
- hass, {"unit_time": "s"}, times=[20, 30], values=[10, -10], expected_state=-2
+ hass,
+ {"unit_time": TIME_SECONDS},
+ times=[20, 30],
+ values=[10, -10],
+ expected_state=-2,
)
@@ -137,7 +154,12 @@ async def test_data_moving_average_for_discrete_sensor(hass):
times = list(range(0, 1800 + 30, 30))
config, entity_id = await _setup_sensor(
- hass, {"time_window": {"seconds": time_window}, "unit_time": "min", "round": 1}
+ hass,
+ {
+ "time_window": {"seconds": time_window},
+ "unit_time": TIME_MINUTES,
+ "round": 1,
+ },
) # two minute window
for time, value in zip(times, temperature_values):
@@ -186,7 +208,7 @@ async def test_prefix(hass):
# Testing a power sensor at 1000 Watts for 1hour = 0kW/h
assert round(float(state.state), config["sensor"]["round"]) == 0.0
- assert state.attributes.get("unit_of_measurement") == "kW/h"
+ assert state.attributes.get("unit_of_measurement") == f"kW/{TIME_HOURS}"
async def test_suffix(hass):
@@ -198,7 +220,7 @@ async def test_suffix(hass):
"source": "sensor.bytes_per_second",
"round": 2,
"unit_prefix": "k",
- "unit_time": "s",
+ "unit_time": TIME_SECONDS,
}
}
diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py
index c8d0e334412..9cc794380f9 100644
--- a/tests/components/device_sun_light_trigger/test_init.py
+++ b/tests/components/device_sun_light_trigger/test_init.py
@@ -11,9 +11,7 @@ from homeassistant.components import (
group,
light,
)
-from homeassistant.components.device_tracker.const import (
- ENTITY_ID_FORMAT as DT_ENTITY_ID_FORMAT,
-)
+from homeassistant.components.device_tracker.const import DOMAIN
from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
@@ -36,7 +34,6 @@ def scanner(hass):
"homeassistant.components.device_tracker.legacy.load_yaml_config_file",
return_value={
"device_1": {
- "hide_if_away": False,
"mac": "DEV1",
"name": "Unnamed Device",
"picture": "http://example.com/dev1.jpg",
@@ -44,7 +41,6 @@ def scanner(hass):
"vendor": None,
},
"device_2": {
- "hide_if_away": False,
"mac": "DEV2",
"name": "Unnamed Device",
"picture": "http://example.com/dev2.jpg",
@@ -122,7 +118,7 @@ async def test_lights_turn_on_when_coming_home_after_sun_set(hass, scanner):
hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}}
)
- hass.states.async_set(DT_ENTITY_ID_FORMAT.format("device_2"), STATE_HOME)
+ hass.states.async_set(f"{DOMAIN}.device_2", STATE_HOME)
await hass.async_block_till_done()
@@ -133,8 +129,8 @@ async def test_lights_turn_on_when_coming_home_after_sun_set(hass, scanner):
async def test_lights_turn_on_when_coming_home_after_sun_set_person(hass, scanner):
"""Test lights turn on when coming home after sun set."""
- device_1 = DT_ENTITY_ID_FORMAT.format("device_1")
- device_2 = DT_ENTITY_ID_FORMAT.format("device_2")
+ device_1 = f"{DOMAIN}.device_1"
+ device_2 = f"{DOMAIN}.device_2"
test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC)
with patch("homeassistant.util.dt.utcnow", return_value=test_time):
diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py
new file mode 100644
index 00000000000..9b6a85cf8a0
--- /dev/null
+++ b/tests/components/device_tracker/test_config_entry.py
@@ -0,0 +1,19 @@
+"""Test Device Tracker config entry things."""
+from homeassistant.components.device_tracker import config_entry
+
+
+def test_tracker_entity():
+ """Test tracker entity."""
+
+ class TestEntry(config_entry.TrackerEntity):
+ """Mock tracker class."""
+
+ should_poll = False
+
+ instance = TestEntry()
+
+ assert instance.force_update
+
+ instance.should_poll = True
+
+ assert not instance.force_update
diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py
index 4d82f93a029..1a21ad4a7a4 100644
--- a/tests/components/device_tracker/test_init.py
+++ b/tests/components/device_tracker/test_init.py
@@ -15,7 +15,6 @@ from homeassistant.const import (
ATTR_ENTITY_PICTURE,
ATTR_FRIENDLY_NAME,
ATTR_GPS_ACCURACY,
- ATTR_HIDDEN,
ATTR_ICON,
ATTR_LATITUDE,
ATTR_LONGITUDE,
@@ -57,7 +56,7 @@ def mock_yaml_devices(hass):
async def test_is_on(hass):
"""Test is_on method."""
- entity_id = const.ENTITY_ID_FORMAT.format("test")
+ entity_id = f"{const.DOMAIN}.test"
hass.states.async_set(entity_id, STATE_HOME)
@@ -107,7 +106,6 @@ async def test_reading_yaml_config(hass, yaml_devices):
"AB:CD:EF:GH:IJ",
"Test name",
picture="http://test.picture",
- hide_if_away=True,
icon="mdi:kettle",
)
await hass.async_add_executor_job(
@@ -121,7 +119,6 @@ async def test_reading_yaml_config(hass, yaml_devices):
assert device.track == config.track
assert device.mac == config.mac
assert device.config_picture == config.config_picture
- assert device.away_hide == config.away_hide
assert device.consider_home == config.consider_home
assert device.icon == config.icon
@@ -271,7 +268,7 @@ async def test_entity_attributes(hass, mock_device_tracker_conf):
"""Test the entity attributes."""
devices = mock_device_tracker_conf
dev_id = "test_entity"
- entity_id = const.ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{const.DOMAIN}.{dev_id}"
friendly_name = "Paulus"
picture = "http://placehold.it/200x200"
icon = "mdi:kettle"
@@ -284,7 +281,6 @@ async def test_entity_attributes(hass, mock_device_tracker_conf):
None,
friendly_name,
picture,
- hide_if_away=True,
icon=icon,
)
devices.append(device)
@@ -299,25 +295,6 @@ async def test_entity_attributes(hass, mock_device_tracker_conf):
assert picture == attrs.get(ATTR_ENTITY_PICTURE)
-async def test_device_hidden(hass, mock_device_tracker_conf):
- """Test hidden devices."""
- devices = mock_device_tracker_conf
- dev_id = "test_entity"
- entity_id = const.ENTITY_ID_FORMAT.format(dev_id)
- device = legacy.Device(
- hass, timedelta(seconds=180), True, dev_id, None, hide_if_away=True
- )
- devices.append(device)
-
- scanner = getattr(hass.components, "test.device_tracker").SCANNER
- scanner.reset()
-
- with assert_setup_component(1, device_tracker.DOMAIN):
- assert await async_setup_component(hass, device_tracker.DOMAIN, TEST_PLATFORM)
-
- assert hass.states.get(entity_id).attributes.get(ATTR_HIDDEN)
-
-
@patch("homeassistant.components.device_tracker.legacy." "DeviceTracker.async_see")
async def test_see_service(mock_see, hass):
"""Test the see service with a unicode dev_id and NO MAC."""
@@ -350,7 +327,7 @@ async def test_see_service_guard_config_entry(hass, mock_device_tracker_conf):
"""Test the guard if the device is registered in the entity registry."""
mock_entry = Mock()
dev_id = "test"
- entity_id = const.ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{const.DOMAIN}.{dev_id}"
mock_registry(hass, {entity_id: mock_entry})
devices = mock_device_tracker_conf
assert await async_setup_component(hass, device_tracker.DOMAIN, TEST_PLATFORM)
@@ -609,17 +586,6 @@ async def test_picture_and_icon_on_see_discovery(mock_device_tracker_conf, hass)
assert mock_device_tracker_conf[0].entity_picture == "pic_url"
-async def test_default_hide_if_away_is_used(mock_device_tracker_conf, hass):
- """Test that default track_new is used."""
- tracker = legacy.DeviceTracker(
- hass, timedelta(seconds=60), False, {device_tracker.CONF_AWAY_HIDE: True}, []
- )
- await tracker.async_see(dev_id=12)
- await hass.async_block_till_done()
- assert len(mock_device_tracker_conf) == 1
- assert mock_device_tracker_conf[0].away_hide
-
-
async def test_backward_compatibility_for_track_new(mock_device_tracker_conf, hass):
"""Test backward compatibility for track new."""
tracker = legacy.DeviceTracker(
diff --git a/tests/components/directv/__init__.py b/tests/components/directv/__init__.py
index 9a32215e53d..876b1e311ab 100644
--- a/tests/components/directv/__init__.py
+++ b/tests/components/directv/__init__.py
@@ -1 +1,189 @@
-"""Tests for the directv component."""
+"""Tests for the DirecTV component."""
+from DirectPy import DIRECTV
+
+from homeassistant.components.directv.const import DOMAIN
+from homeassistant.const import CONF_HOST
+from homeassistant.helpers.typing import HomeAssistantType
+
+from tests.common import MockConfigEntry
+
+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",
+}
+
+
+class MockDirectvClass(DIRECTV):
+ """A fake DirecTV DVR device."""
+
+ 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,
+ )
+
+ self._play = False
+ self._standby = True
+
+ if self.clientAddr == CLIENT_ADDRESS:
+ self.attributes = RECORDING
+ self._standby = False
+ else:
+ self.attributes = LIVE
+
+ def get_locations(self):
+ """Mock for get_locations method."""
+ return MOCK_GET_LOCATIONS
+
+ def get_serial_num(self):
+ """Mock for get_serial_num method."""
+ test_serial_num = {
+ "serialNum": "9999999999",
+ "status": {
+ "code": 200,
+ "commandResult": 0,
+ "msg": "OK.",
+ "query": "/info/getSerialNum",
+ },
+ }
+
+ return test_serial_num
+
+ def get_standby(self):
+ """Mock for get_standby method."""
+ return self._standby
+
+ 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)
+
+
+async def setup_integration(
+ hass: HomeAssistantType, skip_entry_setup: bool = False
+) -> MockConfigEntry:
+ """Set up the DirecTV integration in Home Assistant."""
+ entry = MockConfigEntry(
+ domain=DOMAIN, unique_id=RECEIVER_ID, 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/directv/test_config_flow.py b/tests/components/directv/test_config_flow.py
new file mode 100644
index 00000000000..bd5d8b83419
--- /dev/null
+++ b/tests/components/directv/test_config_flow.py
@@ -0,0 +1,232 @@
+"""Test the DirecTV config flow."""
+from typing import Any, Dict, Optional
+
+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.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.common import MockConfigEntry
+from tests.components.directv import (
+ HOST,
+ RECEIVER_ID,
+ SSDP_LOCATION,
+ UPNP_SERIAL,
+ MockDirectvClass,
+)
+
+
+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", {})
+ 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.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."""
+ 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},
+ )
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["step_id"] == "ssdp_confirm"
+ assert result["description_placeholders"] == {CONF_NAME: HOST}
+
+ 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"], {})
+
+ 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_confirm_abort(hass: HomeAssistantType) -> None:
+ """Test we handle SSDP confirm cannot connect 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},
+ )
+
+ 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_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},
+ )
+
+ 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_ABORT
+
+ await hass.async_block_till_done()
+ assert len(mock_validate_input.mock_calls) == 1
diff --git a/tests/components/directv/test_init.py b/tests/components/directv/test_init.py
new file mode 100644
index 00000000000..02e97b9b015
--- /dev/null
+++ b/tests/components/directv/test_init.py
@@ -0,0 +1,48 @@
+"""Tests for the Roku integration."""
+from asynctest import patch
+from requests.exceptions import RequestException
+
+from homeassistant.components.directv.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.directv import MockDirectvClass, setup_integration
+
+# pylint: disable=redefined-outer-name
+
+
+async def test_config_entry_not_ready(hass: HomeAssistantType) -> 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)
+
+ assert entry.state == ENTRY_STATE_SETUP_RETRY
+
+
+async def test_unload_config_entry(hass: HomeAssistantType) -> 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)
+
+ 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/directv/test_media_player.py b/tests/components/directv/test_media_player.py
index 449147c3648..f7cf63355a8 100644
--- a/tests/components/directv/test_media_player.py
+++ b/tests/components/directv/test_media_player.py
@@ -1,17 +1,16 @@
"""The tests for the DirecTV Media player platform."""
from datetime import datetime, timedelta
-from unittest.mock import call, patch
+from typing import Optional
-import pytest
-import requests
+from asynctest import patch
+from pytest import fixture
+from requests import RequestException
from homeassistant.components.directv.media_player import (
ATTR_MEDIA_CURRENTLY_RECORDING,
ATTR_MEDIA_RATING,
ATTR_MEDIA_RECORDED,
ATTR_MEDIA_START_TIME,
- DEFAULT_DEVICE,
- DEFAULT_PORT,
)
from homeassistant.components.media_player.const import (
ATTR_INPUT_SOURCE,
@@ -24,7 +23,7 @@ from homeassistant.components.media_player.const import (
ATTR_MEDIA_POSITION_UPDATED_AT,
ATTR_MEDIA_SERIES_TITLE,
ATTR_MEDIA_TITLE,
- DOMAIN,
+ DOMAIN as MP_DOMAIN,
MEDIA_TYPE_TVSHOW,
SERVICE_PLAY_MEDIA,
SUPPORT_NEXT_TRACK,
@@ -38,10 +37,6 @@ from homeassistant.components.media_player.const import (
)
from homeassistant.const import (
ATTR_ENTITY_ID,
- CONF_DEVICE,
- CONF_HOST,
- CONF_NAME,
- CONF_PORT,
SERVICE_MEDIA_NEXT_TRACK,
SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY,
@@ -54,183 +49,117 @@ from homeassistant.const import (
STATE_PLAYING,
STATE_UNAVAILABLE,
)
-from homeassistant.helpers.discovery import async_load_platform
-from homeassistant.setup import async_setup_component
-import homeassistant.util.dt as dt_util
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.util import dt as dt_util
-from tests.common import async_fire_time_changed
+from tests.common import MockConfigEntry, async_fire_time_changed
+from tests.components.directv import (
+ DOMAIN,
+ MOCK_GET_LOCATIONS_MULTIPLE,
+ RECORDING,
+ MockDirectvClass,
+ setup_integration,
+)
-CLIENT_ENTITY_ID = "media_player.client_dvr"
-MAIN_ENTITY_ID = "media_player.main_dvr"
-IP_ADDRESS = "127.0.0.1"
+ATTR_UNIQUE_ID = "unique_id"
+CLIENT_ENTITY_ID = f"{MP_DOMAIN}.bedroom_client"
+MAIN_ENTITY_ID = f"{MP_DOMAIN}.main_dvr"
-DISCOVERY_INFO = {"host": IP_ADDRESS, "serial": 1234}
-
-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",
-}
-
-LOCATIONS = [{"locationName": "Main DVR", "clientAddr": DEFAULT_DEVICE}]
-
-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.",
-}
-
-WORKING_CONFIG = {
- "media_player": {
- "platform": "directv",
- CONF_HOST: IP_ADDRESS,
- CONF_NAME: "Main DVR",
- CONF_PORT: DEFAULT_PORT,
- CONF_DEVICE: DEFAULT_DEVICE,
- }
-}
+# pylint: disable=redefined-outer-name
-@pytest.fixture
-def client_dtv():
- """Fixture for a client device."""
- mocked_dtv = MockDirectvClass("mock_ip")
- mocked_dtv.attributes = RECORDING
- mocked_dtv._standby = False
- return mocked_dtv
-
-
-@pytest.fixture
-def main_dtv():
- """Fixture for main DVR."""
- return MockDirectvClass("mock_ip")
-
-
-@pytest.fixture
-def dtv_side_effect(client_dtv, main_dtv):
- """Fixture to create DIRECTV instance for main and client."""
-
- def mock_dtv(ip, port, client_addr):
- if client_addr != "0":
- mocked_dtv = client_dtv
- else:
- mocked_dtv = main_dtv
- mocked_dtv._host = ip
- mocked_dtv._port = port
- mocked_dtv._device = client_addr
- return mocked_dtv
-
- return mock_dtv
-
-
-@pytest.fixture
-def mock_now():
+@fixture
+def mock_now() -> datetime:
"""Fixture for dtutil.now."""
return dt_util.utcnow()
-@pytest.fixture
-def platforms(hass, dtv_side_effect, mock_now):
- """Fixture for setting up test platforms."""
- config = {
- "media_player": [
- {
- "platform": "directv",
- "name": "Main DVR",
- "host": IP_ADDRESS,
- "port": DEFAULT_PORT,
- "device": DEFAULT_DEVICE,
- },
- {
- "platform": "directv",
- "name": "Client DVR",
- "host": IP_ADDRESS,
- "port": DEFAULT_PORT,
- "device": "1",
- },
- ]
- }
-
+async def setup_directv(hass: HomeAssistantType) -> MockConfigEntry:
+ """Set up mock DirecTV integration."""
with patch(
- "homeassistant.components.directv.media_player.DIRECTV",
- side_effect=dtv_side_effect,
- ), patch("homeassistant.util.dt.utcnow", return_value=mock_now):
- hass.loop.run_until_complete(async_setup_component(hass, DOMAIN, config))
- hass.loop.run_until_complete(hass.async_block_till_done())
- yield
+ "homeassistant.components.directv.DIRECTV", new=MockDirectvClass,
+ ):
+ return await setup_integration(hass)
-async def async_turn_on(hass, entity_id=None):
+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:
"""Turn on specified media player or all."""
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data)
+ await hass.services.async_call(MP_DOMAIN, SERVICE_TURN_ON, data)
-async def async_turn_off(hass, entity_id=None):
+async def async_turn_off(
+ hass: HomeAssistantType, entity_id: Optional[str] = None
+) -> None:
"""Turn off specified media player or all."""
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data)
+ await hass.services.async_call(MP_DOMAIN, SERVICE_TURN_OFF, data)
-async def async_media_pause(hass, entity_id=None):
+async def async_media_pause(
+ hass: HomeAssistantType, entity_id: Optional[str] = None
+) -> None:
"""Send the media player the command for pause."""
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- await hass.services.async_call(DOMAIN, SERVICE_MEDIA_PAUSE, data)
+ await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PAUSE, data)
-async def async_media_play(hass, entity_id=None):
+async def async_media_play(
+ hass: HomeAssistantType, entity_id: Optional[str] = None
+) -> None:
"""Send the media player the command for play/pause."""
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- await hass.services.async_call(DOMAIN, SERVICE_MEDIA_PLAY, data)
+ await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data)
-async def async_media_stop(hass, entity_id=None):
+async def async_media_stop(
+ hass: HomeAssistantType, entity_id: Optional[str] = None
+) -> None:
"""Send the media player the command for stop."""
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- await hass.services.async_call(DOMAIN, SERVICE_MEDIA_STOP, data)
+ await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_STOP, data)
-async def async_media_next_track(hass, entity_id=None):
+async def async_media_next_track(
+ hass: HomeAssistantType, entity_id: Optional[str] = None
+) -> None:
"""Send the media player the command for next track."""
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- await hass.services.async_call(DOMAIN, SERVICE_MEDIA_NEXT_TRACK, data)
+ await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, data)
-async def async_media_previous_track(hass, entity_id=None):
+async def async_media_previous_track(
+ hass: HomeAssistantType, entity_id: Optional[str] = None
+) -> None:
"""Send the media player the command for prev track."""
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- await hass.services.async_call(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, data)
+ await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, data)
-async def async_play_media(hass, media_type, media_id, entity_id=None, enqueue=None):
+async def async_play_media(
+ hass: HomeAssistantType,
+ media_type: str,
+ media_id: str,
+ entity_id: Optional[str] = None,
+ enqueue: Optional[str] = None,
+) -> None:
"""Send the media player the command for playing media."""
data = {ATTR_MEDIA_CONTENT_TYPE: media_type, ATTR_MEDIA_CONTENT_ID: media_id}
@@ -240,159 +169,40 @@ async def async_play_media(hass, media_type, media_id, entity_id=None, enqueue=N
if enqueue:
data[ATTR_MEDIA_ENQUEUE] = enqueue
- await hass.services.async_call(DOMAIN, SERVICE_PLAY_MEDIA, data)
+ await hass.services.async_call(MP_DOMAIN, SERVICE_PLAY_MEDIA, data)
-class MockDirectvClass:
- """A fake DirecTV DVR device."""
-
- def __init__(self, ip, port=8080, clientAddr="0"):
- """Initialize the fake DirecTV device."""
- self._host = ip
- self._port = port
- self._device = clientAddr
- self._standby = True
- self._play = False
-
- self._locations = LOCATIONS
-
- self.attributes = LIVE
-
- def get_locations(self):
- """Mock for get_locations method."""
- test_locations = {
- "locations": self._locations,
- "status": {
- "code": 200,
- "commandResult": 0,
- "msg": "OK.",
- "query": "/info/getLocations",
- },
- }
-
- return test_locations
-
- 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 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)
+async def test_setup(hass: HomeAssistantType) -> None:
+ """Test setup with basic config."""
+ await setup_directv(hass)
+ assert hass.states.get(MAIN_ENTITY_ID)
-async def test_setup_platform_config(hass):
- """Test setting up the platform from configuration."""
- with patch(
- "homeassistant.components.directv.media_player.DIRECTV", new=MockDirectvClass
- ):
+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 async_setup_component(hass, DOMAIN, WORKING_CONFIG)
- await hass.async_block_till_done()
-
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state
- assert len(hass.states.async_entity_ids("media_player")) == 1
+ assert hass.states.get(MAIN_ENTITY_ID)
+ assert hass.states.get(CLIENT_ENTITY_ID)
-async def test_setup_platform_discover(hass):
- """Test setting up the platform from discovery."""
- with patch(
- "homeassistant.components.directv.media_player.DIRECTV", new=MockDirectvClass
- ):
+async def test_unique_id(hass: HomeAssistantType) -> None:
+ """Test unique id."""
+ await setup_directv_with_locations(hass)
- hass.async_create_task(
- async_load_platform(
- hass, DOMAIN, "directv", DISCOVERY_INFO, {"media_player": {}}
- )
- )
- await hass.async_block_till_done()
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state
- assert len(hass.states.async_entity_ids("media_player")) == 1
+ main = entity_registry.async_get(MAIN_ENTITY_ID)
+ assert main.unique_id == "028877455858"
+
+ client = entity_registry.async_get(CLIENT_ENTITY_ID)
+ assert client.unique_id == "2CA17D1CD30X"
-async def test_setup_platform_discover_duplicate(hass):
- """Test setting up the platform from discovery."""
- with patch(
- "homeassistant.components.directv.media_player.DIRECTV", new=MockDirectvClass
- ):
-
- await async_setup_component(hass, DOMAIN, WORKING_CONFIG)
- await hass.async_block_till_done()
- hass.async_create_task(
- async_load_platform(
- hass, DOMAIN, "directv", DISCOVERY_INFO, {"media_player": {}}
- )
- )
- await hass.async_block_till_done()
-
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state
- assert len(hass.states.async_entity_ids("media_player")) == 1
-
-
-async def test_setup_platform_discover_client(hass):
- """Test setting up the platform from discovery."""
- LOCATIONS.append({"locationName": "Client 1", "clientAddr": "1"})
- LOCATIONS.append({"locationName": "Client 2", "clientAddr": "2"})
-
- with patch(
- "homeassistant.components.directv.media_player.DIRECTV", new=MockDirectvClass
- ):
-
- await async_setup_component(hass, DOMAIN, WORKING_CONFIG)
- await hass.async_block_till_done()
-
- hass.async_create_task(
- async_load_platform(
- hass, DOMAIN, "directv", DISCOVERY_INFO, {"media_player": {}}
- )
- )
- await hass.async_block_till_done()
-
- del LOCATIONS[-1]
- del LOCATIONS[-1]
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state
- state = hass.states.get("media_player.client_1")
- assert state
- state = hass.states.get("media_player.client_2")
- assert state
-
- assert len(hass.states.async_entity_ids("media_player")) == 3
-
-
-async def test_supported_features(hass, platforms):
+async def test_supported_features(hass: HomeAssistantType) -> None:
"""Test supported features."""
+ await setup_directv_with_locations(hass)
+
# Features supported for main DVR
state = hass.states.get(MAIN_ENTITY_ID)
assert (
@@ -420,8 +230,12 @@ async def test_supported_features(hass, platforms):
)
-async def test_check_attributes(hass, platforms, mock_now):
+async def test_check_attributes(
+ hass: HomeAssistantType, mock_now: dt_util.dt.datetime
+) -> None:
"""Test attributes."""
+ await setup_directv_with_locations(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)
@@ -468,8 +282,12 @@ async def test_check_attributes(hass, platforms, mock_now):
assert state.attributes.get(ATTR_MEDIA_POSITION_UPDATED_AT) == next_update
-async def test_main_services(hass, platforms, main_dtv, mock_now):
+async def test_main_services(
+ hass: HomeAssistantType, mock_now: dt_util.dt.datetime
+) -> None:
"""Test the different services."""
+ 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)
@@ -478,77 +296,50 @@ async def test_main_services(hass, platforms, main_dtv, mock_now):
state = hass.states.get(MAIN_ENTITY_ID)
assert state.state == STATE_OFF
- # All these should call key_press in our class.
- with patch.object(
- main_dtv, "key_press", wraps=main_dtv.key_press
- ) as mock_key_press, patch.object(
- main_dtv, "tune_channel", wraps=main_dtv.tune_channel
- ) as mock_tune_channel, patch.object(
- main_dtv, "get_tuned", wraps=main_dtv.get_tuned
- ) as mock_get_tuned, patch.object(
- main_dtv, "get_standby", wraps=main_dtv.get_standby
- ) as mock_get_standby:
+ # 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
- # Turn main DVR on. When turning on DVR is playing.
- await async_turn_on(hass, MAIN_ENTITY_ID)
- await hass.async_block_till_done()
- assert mock_key_press.called
- assert mock_key_press.call_args == call("poweron")
- 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
- # Pause live TV.
- await async_media_pause(hass, MAIN_ENTITY_ID)
- await hass.async_block_till_done()
- assert mock_key_press.called
- assert mock_key_press.call_args == call("pause")
- 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
- # Start play again for live TV.
- await async_media_play(hass, MAIN_ENTITY_ID)
- await hass.async_block_till_done()
- assert mock_key_press.called
- assert mock_key_press.call_args == call("play")
- 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
- # 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()
- assert mock_tune_channel.called
- assert mock_tune_channel.call_args == call("7")
- 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
- # Stop live TV.
- await async_media_stop(hass, MAIN_ENTITY_ID)
- await hass.async_block_till_done()
- assert mock_key_press.called
- assert mock_key_press.call_args == call("stop")
- 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()
- assert mock_key_press.called
- assert mock_key_press.call_args == call("poweroff")
- state = hass.states.get(MAIN_ENTITY_ID)
- assert state.state == STATE_OFF
-
- # There should have been 6 calls to check if DVR is in standby
- assert main_dtv.get_standby.call_count == 6
- assert mock_get_standby.call_count == 6
- # There should be 5 calls to get current info (only 1 time it will
- # not be called as DVR is in standby.)
- assert main_dtv.get_tuned.call_count == 5
- assert mock_get_tuned.call_count == 5
+ # 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, platforms, main_dtv, mock_now):
+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)
@@ -558,11 +349,17 @@ async def test_available(hass, platforms, main_dtv, mock_now):
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=requests.RequestException
- ), patch("homeassistant.util.dt.utcnow", return_value=next_update):
+ 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)
await hass.async_block_till_done()
@@ -571,9 +368,9 @@ async def test_available(hass, platforms, main_dtv, mock_now):
# Make update fail 2nd time within 1 minute
next_update = next_update + timedelta(seconds=30)
- with patch.object(
- main_dtv, "get_standby", side_effect=requests.RequestException
- ), patch("homeassistant.util.dt.utcnow", return_value=next_update):
+ 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)
await hass.async_block_till_done()
@@ -582,9 +379,9 @@ async def test_available(hass, platforms, main_dtv, mock_now):
# 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=requests.RequestException
- ), patch("homeassistant.util.dt.utcnow", return_value=next_update):
+ 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)
await hass.async_block_till_done()
@@ -596,5 +393,6 @@ async def test_available(hass, platforms, main_dtv, mock_now):
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_UNAVAILABLE
diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py
index 426ba552136..ead9e08d00f 100644
--- a/tests/components/dsmr/test_sensor.py
+++ b/tests/components/dsmr/test_sensor.py
@@ -15,6 +15,7 @@ import pytest
from homeassistant.bootstrap import async_setup_component
from homeassistant.components.dsmr.sensor import DerivativeDSMREntity
+from homeassistant.const import TIME_HOURS, VOLUME_CUBIC_METERS
from tests.common import assert_setup_component
@@ -66,7 +67,7 @@ async def test_default_setup(hass, mock_connection_factory):
GAS_METER_READING: MBusObject(
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
- {"value": Decimal(745.695), "unit": "m3"},
+ {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS},
]
),
}
@@ -100,7 +101,7 @@ async def test_default_setup(hass, mock_connection_factory):
# check if gas consumption is parsed correctly
gas_consumption = hass.states.get("sensor.gas_consumption")
assert gas_consumption.state == "745.695"
- assert gas_consumption.attributes.get("unit_of_measurement") == "m3"
+ assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS
async def test_derivative():
@@ -118,7 +119,7 @@ async def test_derivative():
"1.0.0": MBusObject(
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
- {"value": Decimal(745.695), "unit": "m3"},
+ {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS},
]
)
}
@@ -130,7 +131,7 @@ async def test_derivative():
"1.0.0": MBusObject(
[
{"value": datetime.datetime.fromtimestamp(1551642543)},
- {"value": Decimal(745.698), "unit": "m3"},
+ {"value": Decimal(745.698), "unit": VOLUME_CUBIC_METERS},
]
)
}
@@ -140,7 +141,7 @@ async def test_derivative():
abs(entity.state - 0.033) < 0.00001
), "state should be hourly usage calculated from first and second update"
- assert entity.unit_of_measurement == "m3/h"
+ assert entity.unit_of_measurement == f"{VOLUME_CUBIC_METERS}/{TIME_HOURS}"
async def test_v4_meter(hass, mock_connection_factory):
@@ -159,7 +160,7 @@ async def test_v4_meter(hass, mock_connection_factory):
HOURLY_GAS_METER_READING: MBusObject(
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
- {"value": Decimal(745.695), "unit": "m3"},
+ {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS},
]
),
ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]),
@@ -184,7 +185,7 @@ async def test_v4_meter(hass, mock_connection_factory):
# check if gas consumption is parsed correctly
gas_consumption = hass.states.get("sensor.gas_consumption")
assert gas_consumption.state == "745.695"
- assert gas_consumption.attributes.get("unit_of_measurement") == "m3"
+ assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS
async def test_v5_meter(hass, mock_connection_factory):
@@ -203,7 +204,7 @@ async def test_v5_meter(hass, mock_connection_factory):
HOURLY_GAS_METER_READING: MBusObject(
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
- {"value": Decimal(745.695), "unit": "m³"},
+ {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS},
]
),
ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]),
@@ -228,7 +229,7 @@ async def test_v5_meter(hass, mock_connection_factory):
# check if gas consumption is parsed correctly
gas_consumption = hass.states.get("sensor.gas_consumption")
assert gas_consumption.state == "745.695"
- assert gas_consumption.attributes.get("unit_of_measurement") == "m³"
+ assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS
async def test_belgian_meter(hass, mock_connection_factory):
@@ -247,7 +248,7 @@ async def test_belgian_meter(hass, mock_connection_factory):
BELGIUM_HOURLY_GAS_METER_READING: MBusObject(
[
{"value": datetime.datetime.fromtimestamp(1551642213)},
- {"value": Decimal(745.695), "unit": "m3"},
+ {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS},
]
),
ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]),
@@ -272,7 +273,7 @@ async def test_belgian_meter(hass, mock_connection_factory):
# check if gas consumption is parsed correctly
gas_consumption = hass.states.get("sensor.gas_consumption")
assert gas_consumption.state == "745.695"
- assert gas_consumption.attributes.get("unit_of_measurement") == "m3"
+ assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS
async def test_belgian_meter_low(hass, mock_connection_factory):
@@ -284,9 +285,7 @@ async def test_belgian_meter_low(hass, mock_connection_factory):
config = {"platform": "dsmr", "dsmr_version": "5B"}
- telegram = {
- ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0002", "unit": ""}]),
- }
+ telegram = {ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0002", "unit": ""}])}
with assert_setup_component(1):
await async_setup_component(hass, "sensor", {"sensor": config})
diff --git a/tests/components/dynalite/common.py b/tests/components/dynalite/common.py
new file mode 100755
index 00000000000..56554efaa07
--- /dev/null
+++ b/tests/components/dynalite/common.py
@@ -0,0 +1,64 @@
+"""Common functions for tests."""
+from asynctest import CoroutineMock, Mock, call, patch
+
+from homeassistant.components import dynalite
+from homeassistant.helpers import entity_registry
+
+from tests.common import MockConfigEntry
+
+ATTR_SERVICE = "service"
+ATTR_METHOD = "method"
+ATTR_ARGS = "args"
+
+
+def create_mock_device(platform, spec):
+ """Create a dynalite mock device for a platform according to a spec."""
+ device = Mock(spec=spec)
+ device.category = platform
+ device.unique_id = "UNIQUE"
+ device.name = "NAME"
+ device.device_class = "Device Class"
+ return device
+
+
+async def get_entry_id_from_hass(hass):
+ """Get the config entry id from hass."""
+ ent_reg = await entity_registry.async_get_registry(hass)
+ assert ent_reg
+ conf_entries = hass.config_entries.async_entries(dynalite.DOMAIN)
+ assert len(conf_entries) == 1
+ return conf_entries[0].entry_id
+
+
+async def create_entity_from_device(hass, device):
+ """Set up the component and platform and create a light based on the device provided."""
+ host = "1.2.3.4"
+ entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host})
+ entry.add_to_hass(hass)
+ with patch(
+ "homeassistant.components.dynalite.bridge.DynaliteDevices"
+ ) as mock_dyn_dev:
+ mock_dyn_dev().async_setup = CoroutineMock(return_value=True)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+ new_device_func = mock_dyn_dev.mock_calls[1][2]["new_device_func"]
+ new_device_func([device])
+ await hass.async_block_till_done()
+
+
+async def run_service_tests(hass, device, platform, services):
+ """Run a series of service calls and check that the entity and device behave correctly."""
+ for cur_item in services:
+ service = cur_item[ATTR_SERVICE]
+ args = cur_item.get(ATTR_ARGS, {})
+ service_data = {"entity_id": f"{platform}.name", **args}
+ await hass.services.async_call(platform, service, service_data, blocking=True)
+ await hass.async_block_till_done()
+ for check_item in services:
+ check_method = getattr(device, check_item[ATTR_METHOD])
+ if check_item[ATTR_SERVICE] == service:
+ check_method.assert_called_once()
+ assert check_method.mock_calls == [call(**args)]
+ check_method.reset_mock()
+ else:
+ check_method.assert_not_called()
diff --git a/tests/components/dynalite/test_bridge.py b/tests/components/dynalite/test_bridge.py
index 133e03d9f3d..ee6baaa7561 100755
--- a/tests/components/dynalite/test_bridge.py
+++ b/tests/components/dynalite/test_bridge.py
@@ -1,81 +1,85 @@
"""Test Dynalite bridge."""
-from unittest.mock import Mock, call
-from asynctest import patch
-from dynalite_lib import CONF_ALL
-import pytest
+from asynctest import CoroutineMock, Mock, patch
+from dynalite_devices_lib.const import CONF_ALL
from homeassistant.components import dynalite
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from tests.common import MockConfigEntry
-@pytest.fixture
-def dyn_bridge():
- """Define a basic mock bridge."""
- hass = Mock()
+async def test_update_device(hass):
+ """Test that update works."""
host = "1.2.3.4"
- bridge = dynalite.DynaliteBridge(hass, {dynalite.CONF_HOST: host})
- return bridge
-
-
-async def test_update_device(dyn_bridge):
- """Test a successful setup."""
- async_dispatch = Mock()
-
+ entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host})
+ entry.add_to_hass(hass)
with patch(
- "homeassistant.components.dynalite.bridge.async_dispatcher_send", async_dispatch
- ):
- dyn_bridge.update_device(CONF_ALL)
- async_dispatch.assert_called_once()
- assert async_dispatch.mock_calls[0] == call(
- dyn_bridge.hass, f"dynalite-update-{dyn_bridge.host}"
- )
- async_dispatch.reset_mock()
- device = Mock
- device.unique_id = "abcdef"
- dyn_bridge.update_device(device)
- async_dispatch.assert_called_once()
- assert async_dispatch.mock_calls[0] == call(
- dyn_bridge.hass, f"dynalite-update-{dyn_bridge.host}-{device.unique_id}"
- )
+ "homeassistant.components.dynalite.bridge.DynaliteDevices"
+ ) as mock_dyn_dev:
+ mock_dyn_dev().async_setup = CoroutineMock(return_value=True)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ # Not waiting so it add the devices before registration
+ update_device_func = mock_dyn_dev.mock_calls[1][2]["update_device_func"]
+ device = Mock()
+ device.unique_id = "abcdef"
+ wide_func = Mock()
+ async_dispatcher_connect(hass, f"dynalite-update-{host}", wide_func)
+ specific_func = Mock()
+ async_dispatcher_connect(
+ hass, f"dynalite-update-{host}-{device.unique_id}", specific_func
+ )
+ update_device_func(CONF_ALL)
+ await hass.async_block_till_done()
+ wide_func.assert_called_once()
+ specific_func.assert_not_called()
+ update_device_func(device)
+ await hass.async_block_till_done()
+ wide_func.assert_called_once()
+ specific_func.assert_called_once()
-async def test_add_devices_then_register(dyn_bridge):
+async def test_add_devices_then_register(hass):
"""Test that add_devices work."""
- # First test empty
- dyn_bridge.add_devices_when_registered([])
- assert not dyn_bridge.waiting_devices
+ host = "1.2.3.4"
+ entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host})
+ entry.add_to_hass(hass)
+ with patch(
+ "homeassistant.components.dynalite.bridge.DynaliteDevices"
+ ) as mock_dyn_dev:
+ mock_dyn_dev().async_setup = CoroutineMock(return_value=True)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ # Not waiting so it add the devices before registration
+ new_device_func = mock_dyn_dev.mock_calls[1][2]["new_device_func"]
# Now with devices
device1 = Mock()
device1.category = "light"
+ device1.name = "NAME"
device2 = Mock()
device2.category = "switch"
- dyn_bridge.add_devices_when_registered([device1, device2])
- reg_func = Mock()
- dyn_bridge.register_add_devices(reg_func)
- reg_func.assert_called_once()
- assert reg_func.mock_calls[0][1][0][0] is device1
+ new_device_func([device1, device2])
+ await hass.async_block_till_done()
+ assert hass.states.get("light.name")
-async def test_register_then_add_devices(dyn_bridge):
+async def test_register_then_add_devices(hass):
"""Test that add_devices work after register_add_entities."""
+ host = "1.2.3.4"
+ entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host})
+ entry.add_to_hass(hass)
+ with patch(
+ "homeassistant.components.dynalite.bridge.DynaliteDevices"
+ ) as mock_dyn_dev:
+ mock_dyn_dev().async_setup = CoroutineMock(return_value=True)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+ new_device_func = mock_dyn_dev.mock_calls[1][2]["new_device_func"]
+ # Now with devices
device1 = Mock()
device1.category = "light"
+ device1.name = "NAME"
device2 = Mock()
device2.category = "switch"
- reg_func = Mock()
- dyn_bridge.register_add_devices(reg_func)
- dyn_bridge.add_devices_when_registered([device1, device2])
- reg_func.assert_called_once()
- assert reg_func.mock_calls[0][1][0][0] is device1
-
-
-async def test_try_connection(dyn_bridge):
- """Test that try connection works."""
- # successful
- with patch.object(dyn_bridge.dynalite_devices, "connected", True):
- assert await dyn_bridge.try_connection()
- # unsuccessful
- with patch.object(dyn_bridge.dynalite_devices, "connected", False), patch(
- "homeassistant.components.dynalite.bridge.CONNECT_INTERVAL", 0
- ):
- assert not await dyn_bridge.try_connection()
+ new_device_func([device1, device2])
+ await hass.async_block_till_done()
+ assert hass.states.get("light.name")
diff --git a/tests/components/dynalite/test_config_flow.py b/tests/components/dynalite/test_config_flow.py
index 1f8be61f646..96e361e260f 100755
--- a/tests/components/dynalite/test_config_flow.py
+++ b/tests/components/dynalite/test_config_flow.py
@@ -1,5 +1,6 @@
"""Test Dynalite config flow."""
-from asynctest import patch
+
+from asynctest import CoroutineMock, patch
from homeassistant import config_entries
from homeassistant.components import dynalite
@@ -7,45 +8,43 @@ from homeassistant.components import dynalite
from tests.common import MockConfigEntry
-async def run_flow(hass, setup, connection):
+async def run_flow(hass, connection):
"""Run a flow with or without errors and return result."""
host = "1.2.3.4"
with patch(
"homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
- return_value=setup,
- ), patch(
- "homeassistant.components.dynalite.bridge.DynaliteDevices.available", connection
- ), patch(
- "homeassistant.components.dynalite.bridge.CONNECT_INTERVAL", 0
+ side_effect=connection,
):
result = await hass.config_entries.flow.async_init(
dynalite.DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={dynalite.CONF_HOST: host},
)
+ await hass.async_block_till_done()
return result
async def test_flow_works(hass):
"""Test a successful config flow."""
- result = await run_flow(hass, True, True)
+ result = await run_flow(hass, [True, True])
assert result["type"] == "create_entry"
+ assert result["result"].state == "loaded"
async def test_flow_setup_fails(hass):
"""Test a flow where async_setup fails."""
- result = await run_flow(hass, False, True)
- assert result["type"] == "abort"
- assert result["reason"] == "bridge_setup_failed"
-
-
-async def test_flow_no_connection(hass):
- """Test a flow where connection times out."""
- result = await run_flow(hass, True, False)
+ result = await run_flow(hass, [False])
assert result["type"] == "abort"
assert result["reason"] == "no_connection"
+async def test_flow_setup_fails_in_setup_entry(hass):
+ """Test a flow where the initial check works but inside setup_entry, the bridge setup fails."""
+ result = await run_flow(hass, [True, False])
+ assert result["type"] == "create_entry"
+ assert result["result"].state == "setup_retry"
+
+
async def test_existing(hass):
"""Test when the entry exists with the same config."""
host = "1.2.3.4"
@@ -55,8 +54,6 @@ async def test_existing(hass):
with patch(
"homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
return_value=True,
- ), patch(
- "homeassistant.components.dynalite.bridge.DynaliteDevices.available", True
):
result = await hass.config_entries.flow.async_init(
dynalite.DOMAIN,
@@ -68,23 +65,30 @@ async def test_existing(hass):
async def test_existing_update(hass):
- """Test when the entry exists with the same config."""
+ """Test when the entry exists with a different config."""
host = "1.2.3.4"
- mock_entry = MockConfigEntry(
- domain=dynalite.DOMAIN, unique_id=host, data={dynalite.CONF_HOST: host}
+ port1 = 7777
+ port2 = 8888
+ entry = MockConfigEntry(
+ domain=dynalite.DOMAIN,
+ data={dynalite.CONF_HOST: host, dynalite.CONF_PORT: port1},
)
- mock_entry.add_to_hass(hass)
+ entry.add_to_hass(hass)
with patch(
- "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
- return_value=True,
- ), patch(
- "homeassistant.components.dynalite.bridge.DynaliteDevices.available", True
- ):
+ "homeassistant.components.dynalite.bridge.DynaliteDevices"
+ ) as mock_dyn_dev:
+ mock_dyn_dev().async_setup = CoroutineMock(return_value=True)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+ mock_dyn_dev().configure.assert_called_once()
+ assert mock_dyn_dev().configure.mock_calls[0][1][0][dynalite.CONF_PORT] == port1
result = await hass.config_entries.flow.async_init(
dynalite.DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
- data={dynalite.CONF_HOST: host, "aaa": "bbb"},
+ data={dynalite.CONF_HOST: host, dynalite.CONF_PORT: port2},
)
+ await hass.async_block_till_done()
+ assert mock_dyn_dev().configure.call_count == 2
+ assert mock_dyn_dev().configure.mock_calls[1][1][0][dynalite.CONF_PORT] == port2
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
- assert mock_entry.data.get("aaa") == "bbb"
diff --git a/tests/components/dynalite/test_init.py b/tests/components/dynalite/test_init.py
index d8ef0d7d259..b74fcd64da0 100755
--- a/tests/components/dynalite/test_init.py
+++ b/tests/components/dynalite/test_init.py
@@ -1,6 +1,7 @@
"""Test Dynalite __init__."""
-from asynctest import patch
+
+from asynctest import call, patch
from homeassistant.components import dynalite
from homeassistant.setup import async_setup_component
@@ -12,51 +13,79 @@ async def test_empty_config(hass):
"""Test with an empty config."""
assert await async_setup_component(hass, dynalite.DOMAIN, {}) is True
assert len(hass.config_entries.flow.async_progress()) == 0
- assert hass.data[dynalite.DOMAIN] == {}
+ assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 0
async def test_async_setup(hass):
"""Test a successful setup."""
host = "1.2.3.4"
with patch(
- "dynalite_devices_lib.DynaliteDevices.async_setup", return_value=True
- ), patch("dynalite_devices_lib.DynaliteDevices.available", True):
+ "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
+ return_value=True,
+ ):
assert await async_setup_component(
hass,
dynalite.DOMAIN,
- {dynalite.DOMAIN: {dynalite.CONF_BRIDGES: [{dynalite.CONF_HOST: host}]}},
+ {
+ dynalite.DOMAIN: {
+ dynalite.CONF_BRIDGES: [
+ {
+ dynalite.CONF_HOST: host,
+ dynalite.CONF_AREA: {"1": {dynalite.CONF_NAME: "Name"}},
+ }
+ ]
+ }
+ },
)
-
- assert len(hass.data[dynalite.DOMAIN]) == 1
+ await hass.async_block_till_done()
+ assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 1
-async def test_async_setup_failed(hass):
- """Test a setup when DynaliteBridge.async_setup fails."""
+async def test_async_setup_bad_config2(hass):
+ """Test a successful with bad config on numbers."""
host = "1.2.3.4"
- with patch("dynalite_devices_lib.DynaliteDevices.async_setup", return_value=False):
- assert await async_setup_component(
+ with patch(
+ "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
+ return_value=True,
+ ):
+ assert not await async_setup_component(
hass,
dynalite.DOMAIN,
- {dynalite.DOMAIN: {dynalite.CONF_BRIDGES: [{dynalite.CONF_HOST: host}]}},
+ {
+ dynalite.DOMAIN: {
+ dynalite.CONF_BRIDGES: [
+ {
+ dynalite.CONF_HOST: host,
+ dynalite.CONF_AREA: {"WRONG": {dynalite.CONF_NAME: "Name"}},
+ }
+ ]
+ }
+ },
)
- assert hass.data[dynalite.DOMAIN] == {}
+ await hass.async_block_till_done()
+ assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 0
async def test_unload_entry(hass):
"""Test being able to unload an entry."""
host = "1.2.3.4"
- entry = MockConfigEntry(domain=dynalite.DOMAIN, data={"host": host})
+ entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host})
entry.add_to_hass(hass)
-
with patch(
- "dynalite_devices_lib.DynaliteDevices.async_setup", return_value=True
- ), patch("dynalite_devices_lib.DynaliteDevices.available", True):
- assert await async_setup_component(
- hass,
- dynalite.DOMAIN,
- {dynalite.DOMAIN: {dynalite.CONF_BRIDGES: [{dynalite.CONF_HOST: host}]}},
- )
- assert hass.data[dynalite.DOMAIN].get(entry.entry_id)
-
- assert await hass.config_entries.async_unload(entry.entry_id)
- assert not hass.data[dynalite.DOMAIN].get(entry.entry_id)
+ "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
+ return_value=True,
+ ):
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+ assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 1
+ with patch.object(
+ hass.config_entries, "async_forward_entry_unload", return_value=True
+ ) as mock_unload:
+ assert await hass.config_entries.async_unload(entry.entry_id)
+ await hass.async_block_till_done()
+ assert mock_unload.call_count == len(dynalite.ENTITY_PLATFORMS)
+ expected_calls = [
+ call(entry, platform) for platform in dynalite.ENTITY_PLATFORMS
+ ]
+ for cur_call in mock_unload.mock_calls:
+ assert cur_call in expected_calls
diff --git a/tests/components/dynalite/test_light.py b/tests/components/dynalite/test_light.py
index 9934bac8720..deea32d2e34 100755
--- a/tests/components/dynalite/test_light.py
+++ b/tests/components/dynalite/test_light.py
@@ -1,78 +1,49 @@
"""Test Dynalite light."""
-from unittest.mock import Mock
-from asynctest import CoroutineMock, patch
+from dynalite_devices_lib.light import DynaliteChannelLightDevice
import pytest
-from homeassistant.components import dynalite
from homeassistant.components.light import SUPPORT_BRIGHTNESS
-from homeassistant.setup import async_setup_component
+
+from .common import (
+ ATTR_METHOD,
+ ATTR_SERVICE,
+ create_entity_from_device,
+ create_mock_device,
+ get_entry_id_from_hass,
+ run_service_tests,
+)
@pytest.fixture
def mock_device():
"""Mock a Dynalite device."""
- device = Mock()
- device.category = "light"
- device.unique_id = "UNIQUE"
- device.name = "NAME"
- device.device_info = {
- "identifiers": {(dynalite.DOMAIN, device.unique_id)},
- "name": device.name,
- "manufacturer": "Dynalite",
- }
- return device
-
-
-async def create_light_from_device(hass, device):
- """Set up the component and platform and create a light based on the device provided."""
- host = "1.2.3.4"
- with patch(
- "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
- return_value=True,
- ), patch(
- "homeassistant.components.dynalite.bridge.DynaliteDevices.available", True
- ):
- assert await async_setup_component(
- hass,
- dynalite.DOMAIN,
- {dynalite.DOMAIN: {dynalite.CONF_BRIDGES: [{dynalite.CONF_HOST: host}]}},
- )
- await hass.async_block_till_done()
- # Find the bridge
- bridge = None
- assert len(hass.data[dynalite.DOMAIN]) == 1
- key = next(iter(hass.data[dynalite.DOMAIN]))
- bridge = hass.data[dynalite.DOMAIN][key]
- bridge.dynalite_devices.newDeviceFunc([device])
- await hass.async_block_till_done()
+ return create_mock_device("light", DynaliteChannelLightDevice)
async def test_light_setup(hass, mock_device):
"""Test a successful setup."""
- await create_light_from_device(hass, mock_device)
+ await create_entity_from_device(hass, mock_device)
entity_state = hass.states.get("light.name")
+ assert entity_state.attributes["friendly_name"] == mock_device.name
assert entity_state.attributes["brightness"] == mock_device.brightness
assert entity_state.attributes["supported_features"] == SUPPORT_BRIGHTNESS
-
-
-async def test_turn_on(hass, mock_device):
- """Test turning a light on."""
- mock_device.async_turn_on = CoroutineMock(return_value=True)
- await create_light_from_device(hass, mock_device)
- await hass.services.async_call(
- "light", "turn_on", {"entity_id": "light.name"}, blocking=True
+ await run_service_tests(
+ hass,
+ mock_device,
+ "light",
+ [
+ {ATTR_SERVICE: "turn_on", ATTR_METHOD: "async_turn_on"},
+ {ATTR_SERVICE: "turn_off", ATTR_METHOD: "async_turn_off"},
+ ],
)
- await hass.async_block_till_done()
- mock_device.async_turn_on.assert_awaited_once()
-async def test_turn_off(hass, mock_device):
- """Test turning a light off."""
- mock_device.async_turn_off = CoroutineMock(return_value=True)
- await create_light_from_device(hass, mock_device)
- await hass.services.async_call(
- "light", "turn_off", {"entity_id": "light.name"}, blocking=True
- )
+async def test_remove_entity(hass, mock_device):
+ """Test when an entity is removed from HA."""
+ await create_entity_from_device(hass, mock_device)
+ assert hass.states.get("light.name")
+ entry_id = await get_entry_id_from_hass(hass)
+ assert await hass.config_entries.async_unload(entry_id)
await hass.async_block_till_done()
- mock_device.async_turn_off.assert_awaited_once()
+ assert not hass.states.get("light.name")
diff --git a/tests/components/dynalite/test_switch.py b/tests/components/dynalite/test_switch.py
new file mode 100755
index 00000000000..7c0c5d632d3
--- /dev/null
+++ b/tests/components/dynalite/test_switch.py
@@ -0,0 +1,34 @@
+"""Test Dynalite switch."""
+
+from dynalite_devices_lib.switch import DynalitePresetSwitchDevice
+import pytest
+
+from .common import (
+ ATTR_METHOD,
+ ATTR_SERVICE,
+ create_entity_from_device,
+ create_mock_device,
+ run_service_tests,
+)
+
+
+@pytest.fixture
+def mock_device():
+ """Mock a Dynalite device."""
+ return create_mock_device("switch", DynalitePresetSwitchDevice)
+
+
+async def test_switch_setup(hass, mock_device):
+ """Test a successful setup."""
+ await create_entity_from_device(hass, mock_device)
+ entity_state = hass.states.get("switch.name")
+ assert entity_state.attributes["friendly_name"] == mock_device.name
+ await run_service_tests(
+ hass,
+ mock_device,
+ "switch",
+ [
+ {ATTR_SERVICE: "turn_on", ATTR_METHOD: "async_turn_on"},
+ {ATTR_SERVICE: "turn_off", ATTR_METHOD: "async_turn_off"},
+ ],
+ )
diff --git a/tests/components/dyson/test_sensor.py b/tests/components/dyson/test_sensor.py
index 442ea913b46..b036e3bbbdb 100644
--- a/tests/components/dyson/test_sensor.py
+++ b/tests/components/dyson/test_sensor.py
@@ -8,7 +8,13 @@ from libpurecool.dyson_pure_cool_link import DysonPureCoolLink
from homeassistant.components import dyson as dyson_parent
from homeassistant.components.dyson import sensor as dyson
-from homeassistant.const import STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT
+from homeassistant.const import (
+ STATE_OFF,
+ TEMP_CELSIUS,
+ TEMP_FAHRENHEIT,
+ TIME_HOURS,
+ UNIT_PERCENTAGE,
+)
from homeassistant.helpers import discovery
from homeassistant.setup import async_setup_component
@@ -123,7 +129,7 @@ class DysonTest(unittest.TestCase):
sensor.entity_id = "sensor.dyson_1"
assert not sensor.should_poll
assert sensor.state is None
- assert sensor.unit_of_measurement == "hours"
+ assert sensor.unit_of_measurement == TIME_HOURS
assert sensor.name == "Device_name Filter Life"
assert sensor.entity_id == "sensor.dyson_1"
sensor.on_message("message")
@@ -135,7 +141,7 @@ class DysonTest(unittest.TestCase):
sensor.entity_id = "sensor.dyson_1"
assert not sensor.should_poll
assert sensor.state == 100
- assert sensor.unit_of_measurement == "hours"
+ assert sensor.unit_of_measurement == TIME_HOURS
assert sensor.name == "Device_name Filter Life"
assert sensor.entity_id == "sensor.dyson_1"
sensor.on_message("message")
@@ -169,7 +175,7 @@ class DysonTest(unittest.TestCase):
sensor.entity_id = "sensor.dyson_1"
assert not sensor.should_poll
assert sensor.state is None
- assert sensor.unit_of_measurement == "%"
+ assert sensor.unit_of_measurement == UNIT_PERCENTAGE
assert sensor.name == "Device_name Humidity"
assert sensor.entity_id == "sensor.dyson_1"
@@ -180,7 +186,7 @@ class DysonTest(unittest.TestCase):
sensor.entity_id = "sensor.dyson_1"
assert not sensor.should_poll
assert sensor.state == 45
- assert sensor.unit_of_measurement == "%"
+ assert sensor.unit_of_measurement == UNIT_PERCENTAGE
assert sensor.name == "Device_name Humidity"
assert sensor.entity_id == "sensor.dyson_1"
@@ -191,7 +197,7 @@ class DysonTest(unittest.TestCase):
sensor.entity_id = "sensor.dyson_1"
assert not sensor.should_poll
assert sensor.state == STATE_OFF
- assert sensor.unit_of_measurement == "%"
+ assert sensor.unit_of_measurement == UNIT_PERCENTAGE
assert sensor.name == "Device_name Humidity"
assert sensor.entity_id == "sensor.dyson_1"
diff --git a/tests/components/facebox/test_image_processing.py b/tests/components/facebox/test_image_processing.py
index 6b248ba1c3c..8506cd2d817 100644
--- a/tests/components/facebox/test_image_processing.py
+++ b/tests/components/facebox/test_image_processing.py
@@ -119,7 +119,7 @@ def mock_open_file():
def test_check_box_health(caplog):
"""Test check box health."""
with requests_mock.Mocker() as mock_req:
- url = "http://{}:{}/healthz".format(MOCK_IP, MOCK_PORT)
+ url = f"http://{MOCK_IP}:{MOCK_PORT}/healthz"
mock_req.get(url, status_code=HTTP_OK, json=MOCK_HEALTH)
assert fb.check_box_health(url, "user", "pass") == MOCK_BOX_ID
@@ -184,7 +184,7 @@ async def test_process_image(hass, mock_healthybox, mock_image):
hass.bus.async_listen("image_processing.detect_face", mock_face_event)
with requests_mock.Mocker() as mock_req:
- url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT)
+ url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/check"
mock_req.post(url, json=MOCK_JSON)
data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data)
@@ -219,7 +219,7 @@ async def test_process_image_errors(hass, mock_healthybox, mock_image, caplog):
# Test connection error.
with requests_mock.Mocker() as mock_req:
- url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT)
+ url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/check"
mock_req.register_uri("POST", url, exc=requests.exceptions.ConnectTimeout)
data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data)
@@ -233,7 +233,7 @@ async def test_process_image_errors(hass, mock_healthybox, mock_image, caplog):
# Now test with bad auth.
with requests_mock.Mocker() as mock_req:
- url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT)
+ url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/check"
mock_req.register_uri("POST", url, status_code=HTTP_UNAUTHORIZED)
data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data)
@@ -253,7 +253,7 @@ async def test_teach_service(
# Test successful teach.
with requests_mock.Mocker() as mock_req:
- url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT)
+ url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach"
mock_req.post(url, status_code=HTTP_OK)
data = {
ATTR_ENTITY_ID: VALID_ENTITY_ID,
@@ -267,7 +267,7 @@ async def test_teach_service(
# Now test with bad auth.
with requests_mock.Mocker() as mock_req:
- url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT)
+ url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach"
mock_req.post(url, status_code=HTTP_UNAUTHORIZED)
data = {
ATTR_ENTITY_ID: VALID_ENTITY_ID,
@@ -282,7 +282,7 @@ async def test_teach_service(
# Now test the failed teaching.
with requests_mock.Mocker() as mock_req:
- url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT)
+ url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach"
mock_req.post(url, status_code=HTTP_BAD_REQUEST, text=MOCK_ERROR_NO_FACE)
data = {
ATTR_ENTITY_ID: VALID_ENTITY_ID,
@@ -297,7 +297,7 @@ async def test_teach_service(
# Now test connection error.
with requests_mock.Mocker() as mock_req:
- url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT)
+ url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach"
mock_req.post(url, exc=requests.exceptions.ConnectTimeout)
data = {
ATTR_ENTITY_ID: VALID_ENTITY_ID,
@@ -313,7 +313,7 @@ async def test_teach_service(
async def test_setup_platform_with_name(hass, mock_healthybox):
"""Set up platform with one entity and a name."""
- named_entity_id = "image_processing.{}".format(MOCK_NAME)
+ named_entity_id = f"image_processing.{MOCK_NAME}"
valid_config_named = VALID_CONFIG.copy()
valid_config_named[ip.DOMAIN][ip.CONF_SOURCE][ip.CONF_NAME] = MOCK_NAME
diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py
index b44ba22d8e5..c46b3a6fcec 100644
--- a/tests/components/fan/test_device_trigger.py
+++ b/tests/components/fan/test_device_trigger.py
@@ -119,14 +119,10 @@ async def test_if_fires_on_state_change(hass, calls):
hass.states.async_set("fan.entity", STATE_ON)
await hass.async_block_till_done()
assert len(calls) == 1
- assert calls[0].data["some"] == "turn_on - device - {} - off - on - None".format(
- "fan.entity"
- )
+ assert calls[0].data["some"] == "turn_on - device - fan.entity - off - on - None"
# Fake that the entity is turning off.
hass.states.async_set("fan.entity", STATE_OFF)
await hass.async_block_till_done()
assert len(calls) == 2
- assert calls[1].data["some"] == "turn_off - device - {} - on - off - None".format(
- "fan.entity"
- )
+ assert calls[1].data["some"] == "turn_off - device - fan.entity - on - off - None"
diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py
index 048be11e079..58a660fcb5d 100644
--- a/tests/components/feedreader/test_init.py
+++ b/tests/components/feedreader/test_init.py
@@ -39,7 +39,7 @@ class TestFeedreaderComponent(unittest.TestCase):
"""Initialize values for this testcase class."""
self.hass = get_test_home_assistant()
# Delete any previously stored data
- data_file = self.hass.config.path("{}.pickle".format("feedreader"))
+ data_file = self.hass.config.path(f"{feedreader.DOMAIN}.pickle")
if exists(data_file):
remove(data_file)
@@ -85,7 +85,7 @@ class TestFeedreaderComponent(unittest.TestCase):
# Loading raw data from fixture and plug in to data object as URL
# works since the third-party feedparser library accepts a URL
# as well as the actual data.
- data_file = self.hass.config.path("{}.pickle".format(feedreader.DOMAIN))
+ data_file = self.hass.config.path(f"{feedreader.DOMAIN}.pickle")
storage = StoredData(data_file)
with patch(
"homeassistant.components.feedreader.track_time_interval"
@@ -179,7 +179,7 @@ class TestFeedreaderComponent(unittest.TestCase):
@mock.patch("feedparser.parse", return_value=None)
def test_feed_parsing_failed(self, mock_parse):
"""Test feed where parsing fails."""
- data_file = self.hass.config.path("{}.pickle".format(feedreader.DOMAIN))
+ data_file = self.hass.config.path(f"{feedreader.DOMAIN}.pickle")
storage = StoredData(data_file)
manager = FeedManager(
"FEED DATA", DEFAULT_SCAN_INTERVAL, DEFAULT_MAX_ENTRIES, self.hass, storage
diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py
index 52524d5b189..bd5ae68cb37 100644
--- a/tests/components/file/test_notify.py
+++ b/tests/components/file/test_notify.py
@@ -56,8 +56,9 @@ class TestNotifyFile(unittest.TestCase):
):
mock_st.return_value.st_size = 0
- title = "{} notifications (Log started: {})\n{}\n".format(
- ATTR_TITLE_DEFAULT, dt_util.utcnow().isoformat(), "-" * 80
+ title = (
+ f"{ATTR_TITLE_DEFAULT} notifications "
+ f"(Log started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n"
)
self.hass.services.call(
@@ -72,12 +73,12 @@ class TestNotifyFile(unittest.TestCase):
if not timestamp:
assert m_open.return_value.write.call_args_list == [
call(title),
- call("{}\n".format(message)),
+ call(f"{message}\n"),
]
else:
assert m_open.return_value.write.call_args_list == [
call(title),
- call("{} {}\n".format(dt_util.utcnow().isoformat(), message)),
+ call(f"{dt_util.utcnow().isoformat()} {message}\n"),
]
def test_notify_file(self):
diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py
index d46fa4eab68..06bf7cfaf12 100644
--- a/tests/components/filter/test_sensor.py
+++ b/tests/components/filter/test_sensor.py
@@ -104,6 +104,7 @@ class TestFilterSensor(unittest.TestCase):
t_0 = dt_util.utcnow() - timedelta(minutes=1)
t_1 = dt_util.utcnow() - timedelta(minutes=2)
t_2 = dt_util.utcnow() - timedelta(minutes=3)
+ t_3 = dt_util.utcnow() - timedelta(minutes=4)
if missing:
fake_states = {}
@@ -111,8 +112,9 @@ class TestFilterSensor(unittest.TestCase):
fake_states = {
"sensor.test_monitored": [
ha.State("sensor.test_monitored", 18.0, last_changed=t_0),
- ha.State("sensor.test_monitored", 19.0, last_changed=t_1),
- ha.State("sensor.test_monitored", 18.2, last_changed=t_2),
+ ha.State("sensor.test_monitored", "unknown", last_changed=t_1),
+ ha.State("sensor.test_monitored", 19.0, last_changed=t_2),
+ ha.State("sensor.test_monitored", 18.2, last_changed=t_3),
]
}
@@ -208,6 +210,17 @@ class TestFilterSensor(unittest.TestCase):
filtered = filt.filter_state(state)
assert 21 == filtered.state
+ def test_unknown_state_outlier(self):
+ """Test issue #32395."""
+ filt = OutlierFilter(window_size=3, precision=2, entity=None, radius=4.0)
+ out = ha.State("sensor.test_monitored", "unknown")
+ for state in [out] + self.values + [out]:
+ try:
+ filtered = filt.filter_state(state)
+ except ValueError:
+ assert state.state == "unknown"
+ assert 21 == filtered.state
+
def test_precision_zero(self):
"""Test if precision of zero returns an integer."""
filt = LowPassFilter(window_size=10, precision=0, entity=None, time_constant=10)
@@ -218,8 +231,12 @@ class TestFilterSensor(unittest.TestCase):
def test_lowpass(self):
"""Test if lowpass filter works."""
filt = LowPassFilter(window_size=10, precision=2, entity=None, time_constant=10)
- for state in self.values:
- filtered = filt.filter_state(state)
+ out = ha.State("sensor.test_monitored", "unknown")
+ for state in [out] + self.values + [out]:
+ try:
+ filtered = filt.filter_state(state)
+ except ValueError:
+ assert state.state == "unknown"
assert 18.05 == filtered.state
def test_range(self):
diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py
index b3d0a008961..13824dff9c3 100644
--- a/tests/components/flux/test_switch.py
+++ b/tests/components/flux/test_switch.py
@@ -923,9 +923,9 @@ async def test_flux_with_multiple_lights(hass):
def event_date(hass, event, now=None):
if event == SUN_EVENT_SUNRISE:
- print("sunrise {}".format(sunrise_time))
+ print(f"sunrise {sunrise_time}")
return sunrise_time
- print("sunset {}".format(sunset_time))
+ print(f"sunset {sunset_time}")
return sunset_time
with patch(
diff --git a/tests/components/foobot/test_sensor.py b/tests/components/foobot/test_sensor.py
index 9c6a17264eb..f8cf38395f2 100644
--- a/tests/components/foobot/test_sensor.py
+++ b/tests/components/foobot/test_sensor.py
@@ -8,7 +8,13 @@ import pytest
from homeassistant.components.foobot import sensor as foobot
import homeassistant.components.sensor as sensor
-from homeassistant.const import TEMP_CELSIUS
+from homeassistant.const import (
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ CONCENTRATION_PARTS_PER_BILLION,
+ CONCENTRATION_PARTS_PER_MILLION,
+ TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
+)
from homeassistant.exceptions import PlatformNotReady
from homeassistant.setup import async_setup_component
@@ -33,12 +39,12 @@ async def test_default_setup(hass, aioclient_mock):
assert await async_setup_component(hass, sensor.DOMAIN, {"sensor": VALID_CONFIG})
metrics = {
- "co2": ["1232.0", "ppm"],
+ "co2": ["1232.0", CONCENTRATION_PARTS_PER_MILLION],
"temperature": ["21.1", TEMP_CELSIUS],
- "humidity": ["49.5", "%"],
- "pm2_5": ["144.8", "µg/m3"],
- "voc": ["340.7", "ppb"],
- "index": ["138.9", "%"],
+ "humidity": ["49.5", UNIT_PERCENTAGE],
+ "pm2_5": ["144.8", CONCENTRATION_MICROGRAMS_PER_CUBIC_METER],
+ "voc": ["340.7", CONCENTRATION_PARTS_PER_BILLION],
+ "index": ["138.9", UNIT_PERCENTAGE],
}
for name, value in metrics.items():
diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py
index 627bf23341d..36243972fb6 100644
--- a/tests/components/frontend/test_init.py
+++ b/tests/components/frontend/test_init.py
@@ -106,15 +106,6 @@ async def test_we_cannot_POST_to_root(mock_http_client):
assert resp.status == 405
-async def test_states_routes(mock_http_client):
- """All served by index."""
- resp = await mock_http_client.get("/states")
- assert resp.status == 200
-
- resp = await mock_http_client.get("/states/group.existing")
- assert resp.status == 200
-
-
async def test_themes_api(hass, hass_ws_client):
"""Test that /api/themes returns correct data."""
assert await async_setup_component(hass, "frontend", CONFIG_THEMES)
@@ -217,7 +208,7 @@ async def test_missing_themes(hass, hass_ws_client):
async def test_extra_urls(mock_http_client_with_urls, mock_onboarded):
"""Test that extra urls are loaded."""
- resp = await mock_http_client_with_urls.get("/states?latest")
+ resp = await mock_http_client_with_urls.get("/lovelace?latest")
assert resp.status == 200
text = await resp.text()
assert text.find('href="https://domain.com/my_extra_url.html"') >= 0
diff --git a/tests/components/frontend/test_storage.py b/tests/components/frontend/test_storage.py
index d907f69bbf9..d4cf1916c52 100644
--- a/tests/components/frontend/test_storage.py
+++ b/tests/components/frontend/test_storage.py
@@ -1,7 +1,7 @@
"""The tests for frontend storage."""
import pytest
-from homeassistant.components.frontend import storage
+from homeassistant.components.frontend import DOMAIN
from homeassistant.setup import async_setup_component
@@ -26,7 +26,7 @@ async def test_get_user_data_empty(hass, hass_ws_client, hass_storage):
async def test_get_user_data(hass, hass_ws_client, hass_admin_user, hass_storage):
"""Test get_user_data command."""
- storage_key = storage.STORAGE_KEY_USER_DATA.format(hass_admin_user.id)
+ storage_key = f"{DOMAIN}.user_data_{hass_admin_user.id}"
hass_storage[storage_key] = {
"key": storage_key,
"version": 1,
@@ -102,7 +102,7 @@ async def test_set_user_data_empty(hass, hass_ws_client, hass_storage):
async def test_set_user_data(hass, hass_ws_client, hass_storage, hass_admin_user):
"""Test set_user_data command with initial data."""
- storage_key = storage.STORAGE_KEY_USER_DATA.format(hass_admin_user.id)
+ storage_key = f"{DOMAIN}.user_data_{hass_admin_user.id}"
hass_storage[storage_key] = {
"version": 1,
"data": {"test-key": "test-value", "test-complex": "string"},
diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py
index 38c7200cce1..8bfbed52a11 100644
--- a/tests/components/geo_json_events/test_geo_location.py
+++ b/tests/components/geo_json_events/test_geo_location.py
@@ -5,8 +5,6 @@ from homeassistant.components import geo_location
from homeassistant.components.geo_json_events.geo_location import (
ATTR_EXTERNAL_ID,
SCAN_INTERVAL,
- SIGNAL_DELETE_ENTITY,
- SIGNAL_UPDATE_ENTITY,
)
from homeassistant.components.geo_location import ATTR_SOURCE
from homeassistant.const import (
@@ -190,8 +188,8 @@ async def test_setup_race_condition(hass):
# Set up some mock feed entries for this test.
mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 15.5, (-31.0, 150.0))
- delete_signal = SIGNAL_DELETE_ENTITY.format("1234")
- update_signal = SIGNAL_UPDATE_ENTITY.format("1234")
+ delete_signal = f"geo_json_events_delete_1234"
+ update_signal = f"geo_json_events_update_1234"
# Patching 'utcnow' to gain more control over the timed update.
utcnow = dt_util.utcnow()
diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py
index 319a79966fd..b988d613d6c 100644
--- a/tests/components/geofency/test_init.py
+++ b/tests/components/geofency/test_init.py
@@ -163,7 +163,7 @@ async def webhook_id(hass, geofency_client):
async def test_data_validation(geofency_client, webhook_id):
"""Test data validation."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
# No data
req = await geofency_client.post(url)
@@ -181,14 +181,14 @@ async def test_data_validation(geofency_client, webhook_id):
async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id):
"""Test GPS based zone enter and exit."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
# Enter the Home zone
req = await geofency_client.post(url, data=GPS_ENTER_HOME)
await hass.async_block_till_done()
assert req.status == HTTP_OK
device_name = slugify(GPS_ENTER_HOME["device"])
- state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state
+ state_name = hass.states.get(f"device_tracker.{device_name}").state
assert STATE_HOME == state_name
# Exit the Home zone
@@ -196,7 +196,7 @@ async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id):
await hass.async_block_till_done()
assert req.status == HTTP_OK
device_name = slugify(GPS_EXIT_HOME["device"])
- state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state
+ state_name = hass.states.get(f"device_tracker.{device_name}").state
assert STATE_NOT_HOME == state_name
# Exit the Home zone with "Send Current Position" enabled
@@ -208,13 +208,13 @@ async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id):
await hass.async_block_till_done()
assert req.status == HTTP_OK
device_name = slugify(GPS_EXIT_HOME["device"])
- current_latitude = hass.states.get(
- "{}.{}".format("device_tracker", device_name)
- ).attributes["latitude"]
+ current_latitude = hass.states.get(f"device_tracker.{device_name}").attributes[
+ "latitude"
+ ]
assert NOT_HOME_LATITUDE == current_latitude
- current_longitude = hass.states.get(
- "{}.{}".format("device_tracker", device_name)
- ).attributes["longitude"]
+ current_longitude = hass.states.get(f"device_tracker.{device_name}").attributes[
+ "longitude"
+ ]
assert NOT_HOME_LONGITUDE == current_longitude
dev_reg = await hass.helpers.device_registry.async_get_registry()
@@ -226,43 +226,43 @@ async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id):
async def test_beacon_enter_and_exit_home(hass, geofency_client, webhook_id):
"""Test iBeacon based zone enter and exit - a.k.a stationary iBeacon."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
# Enter the Home zone
req = await geofency_client.post(url, data=BEACON_ENTER_HOME)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME["name"]))
- state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state
+ device_name = slugify(f"beacon_{BEACON_ENTER_HOME['name']}")
+ state_name = hass.states.get(f"device_tracker.{device_name}").state
assert STATE_HOME == state_name
# Exit the Home zone
req = await geofency_client.post(url, data=BEACON_EXIT_HOME)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME["name"]))
- state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state
+ device_name = slugify(f"beacon_{BEACON_ENTER_HOME['name']}")
+ state_name = hass.states.get(f"device_tracker.{device_name}").state
assert STATE_NOT_HOME == state_name
async def test_beacon_enter_and_exit_car(hass, geofency_client, webhook_id):
"""Test use of mobile iBeacon."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
# Enter the Car away from Home zone
req = await geofency_client.post(url, data=BEACON_ENTER_CAR)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR["name"]))
- state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state
+ device_name = slugify(f"beacon_{BEACON_ENTER_CAR['name']}")
+ state_name = hass.states.get(f"device_tracker.{device_name}").state
assert STATE_NOT_HOME == state_name
# Exit the Car away from Home zone
req = await geofency_client.post(url, data=BEACON_EXIT_CAR)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR["name"]))
- state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state
+ device_name = slugify(f"beacon_{BEACON_ENTER_CAR['name']}")
+ state_name = hass.states.get(f"device_tracker.{device_name}").state
assert STATE_NOT_HOME == state_name
# Enter the Car in the Home zone
@@ -272,29 +272,29 @@ async def test_beacon_enter_and_exit_car(hass, geofency_client, webhook_id):
req = await geofency_client.post(url, data=data)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- device_name = slugify("beacon_{}".format(data["name"]))
- state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state
+ device_name = slugify(f"beacon_{data['name']}")
+ state_name = hass.states.get(f"device_tracker.{device_name}").state
assert STATE_HOME == state_name
# Exit the Car in the Home zone
req = await geofency_client.post(url, data=data)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- device_name = slugify("beacon_{}".format(data["name"]))
- state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state
+ device_name = slugify(f"beacon_{data['name']}")
+ state_name = hass.states.get(f"device_tracker.{device_name}").state
assert STATE_HOME == state_name
async def test_load_unload_entry(hass, geofency_client, webhook_id):
"""Test that the appropriate dispatch signals are added and removed."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
# Enter the Home zone
req = await geofency_client.post(url, data=GPS_ENTER_HOME)
await hass.async_block_till_done()
assert req.status == HTTP_OK
device_name = slugify(GPS_ENTER_HOME["device"])
- state_1 = hass.states.get("{}.{}".format("device_tracker", device_name))
+ state_1 = hass.states.get(f"device_tracker.{device_name}")
assert STATE_HOME == state_1.state
assert len(hass.data[DOMAIN]["devices"]) == 1
@@ -307,7 +307,7 @@ async def test_load_unload_entry(hass, geofency_client, webhook_id):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
- state_2 = hass.states.get("{}.{}".format("device_tracker", device_name))
+ state_2 = hass.states.get(f"device_tracker.{device_name}")
assert state_2 is not None
assert state_1 is not state_2
diff --git a/tests/components/geonetnz_quakes/conftest.py b/tests/components/geonetnz_quakes/conftest.py
new file mode 100644
index 00000000000..7715b17796b
--- /dev/null
+++ b/tests/components/geonetnz_quakes/conftest.py
@@ -0,0 +1,36 @@
+"""Configuration for GeoNet NZ Quakes tests."""
+import pytest
+
+from homeassistant.components.geonetnz_quakes import (
+ CONF_MINIMUM_MAGNITUDE,
+ CONF_MMI,
+ DOMAIN,
+)
+from homeassistant.const import (
+ CONF_LATITUDE,
+ CONF_LONGITUDE,
+ CONF_RADIUS,
+ CONF_SCAN_INTERVAL,
+ CONF_UNIT_SYSTEM,
+)
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture
+def config_entry():
+ """Create a mock GeoNet NZ Quakes config entry."""
+ return MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ CONF_LATITUDE: -41.2,
+ CONF_LONGITUDE: 174.7,
+ CONF_RADIUS: 25,
+ CONF_UNIT_SYSTEM: "metric",
+ CONF_SCAN_INTERVAL: 300.0,
+ CONF_MMI: 4,
+ CONF_MINIMUM_MAGNITUDE: 0.0,
+ },
+ title="-41.2, 174.7",
+ unique_id="-41.2, 174.7",
+ )
diff --git a/tests/components/geonetnz_quakes/test_config_flow.py b/tests/components/geonetnz_quakes/test_config_flow.py
index 494ceaa542d..051873f5360 100644
--- a/tests/components/geonetnz_quakes/test_config_flow.py
+++ b/tests/components/geonetnz_quakes/test_config_flow.py
@@ -1,18 +1,11 @@
"""Define tests for the GeoNet NZ Quakes config flow."""
from datetime import timedelta
-from asynctest import CoroutineMock, patch
-import pytest
-
from homeassistant import data_entry_flow
from homeassistant.components.geonetnz_quakes import (
CONF_MINIMUM_MAGNITUDE,
CONF_MMI,
DOMAIN,
- FEED,
- async_setup_entry,
- async_unload_entry,
- config_flow,
)
from homeassistant.const import (
CONF_LATITUDE,
@@ -22,46 +15,24 @@ from homeassistant.const import (
CONF_UNIT_SYSTEM,
)
-from tests.common import MockConfigEntry
-
-
-@pytest.fixture
-def config_entry():
- """Create a mock GeoNet NZ Quakes config entry."""
- return MockConfigEntry(
- domain=DOMAIN,
- data={
- CONF_LATITUDE: -41.2,
- CONF_LONGITUDE: 174.7,
- CONF_RADIUS: 25,
- CONF_UNIT_SYSTEM: "metric",
- CONF_SCAN_INTERVAL: 300.0,
- CONF_MMI: 4,
- CONF_MINIMUM_MAGNITUDE: 0.0,
- },
- title="-41.2, 174.7",
- )
-
async def test_duplicate_error(hass, config_entry):
"""Test that errors are shown when duplicates are added."""
conf = {CONF_LATITUDE: -41.2, CONF_LONGITUDE: 174.7, CONF_RADIUS: 25}
-
config_entry.add_to_hass(hass)
- flow = config_flow.GeonetnzQuakesFlowHandler()
- flow.hass = hass
- result = await flow.async_step_user(user_input=conf)
- assert result["errors"] == {"base": "identifier_exists"}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}, data=conf
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
async def test_show_form(hass):
"""Test that the form is served with no input."""
- flow = config_flow.GeonetnzQuakesFlowHandler()
- flow.hass = hass
-
- result = await flow.async_step_user(user_input=None)
-
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}
+ )
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
@@ -78,10 +49,9 @@ async def test_step_import(hass):
CONF_MINIMUM_MAGNITUDE: 2.5,
}
- flow = config_flow.GeonetnzQuakesFlowHandler()
- flow.hass = hass
-
- result = await flow.async_step_import(import_config=conf)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "import"}, data=conf
+ )
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "-41.2, 174.7"
assert result["data"] == {
@@ -101,10 +71,9 @@ async def test_step_user(hass):
hass.config.longitude = 174.7
conf = {CONF_RADIUS: 25, CONF_MMI: 4}
- flow = config_flow.GeonetnzQuakesFlowHandler()
- flow.hass = hass
-
- result = await flow.async_step_user(user_input=conf)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}, data=conf
+ )
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "-41.2, 174.7"
assert result["data"] == {
@@ -112,25 +81,6 @@ async def test_step_user(hass):
CONF_LONGITUDE: 174.7,
CONF_RADIUS: 25,
CONF_MMI: 4,
- CONF_UNIT_SYSTEM: "metric",
CONF_SCAN_INTERVAL: 300.0,
CONF_MINIMUM_MAGNITUDE: 0.0,
}
-
-
-async def test_component_unload_config_entry(hass, config_entry):
- """Test that loading and unloading of a config entry works."""
- config_entry.add_to_hass(hass)
- with patch(
- "aio_geojson_geonetnz_quakes.GeonetnzQuakesFeedManager.update",
- new_callable=CoroutineMock,
- ) as mock_feed_manager_update:
- # Load config entry.
- assert await async_setup_entry(hass, config_entry)
- await hass.async_block_till_done()
- assert mock_feed_manager_update.call_count == 1
- assert hass.data[DOMAIN][FEED][config_entry.entry_id] is not None
- # Unload config entry.
- assert await async_unload_entry(hass, config_entry)
- await hass.async_block_till_done()
- assert hass.data[DOMAIN][FEED].get(config_entry.entry_id) is None
diff --git a/tests/components/geonetnz_quakes/test_geo_location.py b/tests/components/geonetnz_quakes/test_geo_location.py
index 0132a07c745..06244227726 100644
--- a/tests/components/geonetnz_quakes/test_geo_location.py
+++ b/tests/components/geonetnz_quakes/test_geo_location.py
@@ -1,11 +1,11 @@
"""The tests for the GeoNet NZ Quakes Feed integration."""
import datetime
-from asynctest import CoroutineMock, patch
+from asynctest import patch
from homeassistant.components import geonetnz_quakes
from homeassistant.components.geo_location import ATTR_SOURCE
-from homeassistant.components.geonetnz_quakes import DEFAULT_SCAN_INTERVAL
+from homeassistant.components.geonetnz_quakes import DEFAULT_SCAN_INTERVAL, DOMAIN, FEED
from homeassistant.components.geonetnz_quakes.geo_location import (
ATTR_DEPTH,
ATTR_EXTERNAL_ID,
@@ -25,6 +25,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
@@ -62,7 +63,7 @@ async def test_setup(hass):
# Patching 'utcnow' to gain more control over the timed update.
utcnow = dt_util.utcnow()
with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch(
- "aio_geojson_client.feed.GeoJsonFeed.update", new_callable=CoroutineMock
+ "aio_geojson_client.feed.GeoJsonFeed.update"
) as mock_feed_update:
mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_2, mock_entry_3]
assert await async_setup_component(hass, geonetnz_quakes.DOMAIN, CONFIG)
@@ -73,6 +74,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.title_1")
assert state is not None
@@ -151,6 +154,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):
@@ -162,15 +166,9 @@ async def test_setup_imperial(hass):
# Patching 'utcnow' to gain more control over the timed update.
utcnow = dt_util.utcnow()
with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch(
- "aio_geojson_client.feed.GeoJsonFeed.update", new_callable=CoroutineMock
+ "aio_geojson_client.feed.GeoJsonFeed.update"
) as mock_feed_update, patch(
- "aio_geojson_client.feed.GeoJsonFeed.__init__",
- new_callable=CoroutineMock,
- create=True,
- ) as mock_feed_init, patch(
- "aio_geojson_client.feed.GeoJsonFeed.last_timestamp",
- new_callable=CoroutineMock,
- create=True,
+ "aio_geojson_client.feed.GeoJsonFeed.last_timestamp", create=True
):
mock_feed_update.return_value = "OK", [mock_entry_1]
assert await async_setup_component(hass, geonetnz_quakes.DOMAIN, CONFIG)
@@ -182,7 +180,12 @@ async def test_setup_imperial(hass):
assert len(all_states) == 2
# Test conversion of 200 miles to kilometers.
- assert mock_feed_init.call_args[1].get("filter_radius") == 321.8688
+ feeds = hass.data[DOMAIN][FEED]
+ assert feeds is not None
+ assert len(feeds) == 1
+ manager = list(feeds.values())[0]
+ # Ensure that the filter value in km is correctly set.
+ assert manager._feed_manager._feed._filter_radius == 321.8688
state = hass.states.get("geo_location.title_1")
assert state is not None
@@ -196,4 +199,5 @@ async def test_setup_imperial(hass):
ATTR_SOURCE: "geonetnz_quakes",
ATTR_ICON: "mdi:pulse",
}
+ # 15.5km (as defined in mock entry) has been converted to 9.6mi.
assert float(state.state) == 9.6
diff --git a/tests/components/geonetnz_quakes/test_init.py b/tests/components/geonetnz_quakes/test_init.py
new file mode 100644
index 00000000000..85724879f7b
--- /dev/null
+++ b/tests/components/geonetnz_quakes/test_init.py
@@ -0,0 +1,21 @@
+"""Define tests for the GeoNet NZ Quakes general setup."""
+from asynctest import patch
+
+from homeassistant.components.geonetnz_quakes import DOMAIN, FEED
+
+
+async def test_component_unload_config_entry(hass, config_entry):
+ """Test that loading and unloading of a config entry works."""
+ config_entry.add_to_hass(hass)
+ with patch(
+ "aio_geojson_geonetnz_quakes.GeonetnzQuakesFeedManager.update"
+ ) as mock_feed_manager_update:
+ # Load config entry.
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+ assert mock_feed_manager_update.call_count == 1
+ assert hass.data[DOMAIN][FEED][config_entry.entry_id] is not None
+ # Unload config entry.
+ assert await hass.config_entries.async_unload(config_entry.entry_id)
+ await hass.async_block_till_done()
+ assert hass.data[DOMAIN][FEED].get(config_entry.entry_id) is None
diff --git a/tests/components/geonetnz_quakes/test_sensor.py b/tests/components/geonetnz_quakes/test_sensor.py
index aecd012ba1c..7d7f8333bc0 100644
--- a/tests/components/geonetnz_quakes/test_sensor.py
+++ b/tests/components/geonetnz_quakes/test_sensor.py
@@ -1,7 +1,7 @@
"""The tests for the GeoNet NZ Quakes Feed integration."""
import datetime
-from asynctest import CoroutineMock, patch
+from asynctest import patch
from homeassistant.components import geonetnz_quakes
from homeassistant.components.geonetnz_quakes import DEFAULT_SCAN_INTERVAL
@@ -55,7 +55,7 @@ async def test_setup(hass):
# Patching 'utcnow' to gain more control over the timed update.
utcnow = dt_util.utcnow()
with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch(
- "aio_geojson_client.feed.GeoJsonFeed.update", new_callable=CoroutineMock
+ "aio_geojson_client.feed.GeoJsonFeed.update"
) as mock_feed_update:
mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_2, mock_entry_3]
assert await async_setup_component(hass, geonetnz_quakes.DOMAIN, CONFIG)
diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py
index 4aace6f5484..ad7b6b12001 100644
--- a/tests/components/google/test_calendar.py
+++ b/tests/components/google/test_calendar.py
@@ -218,7 +218,7 @@ async def test_offset_in_progress_event(hass, mock_next_event):
event = copy.deepcopy(TEST_EVENT)
event["start"]["dateTime"] = start
event["end"]["dateTime"] = end
- event["summary"] = "{} !!-15".format(event_summary)
+ event["summary"] = f"{event_summary} !!-15"
mock_next_event.return_value.event = event
assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG})
@@ -250,7 +250,7 @@ async def test_all_day_offset_in_progress_event(hass, mock_next_event):
event = copy.deepcopy(TEST_EVENT)
event["start"]["date"] = start
event["end"]["date"] = end
- event["summary"] = "{} !!-25:0".format(event_summary)
+ event["summary"] = f"{event_summary} !!-25:0"
mock_next_event.return_value.event = event
assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG})
@@ -282,7 +282,7 @@ async def test_all_day_offset_event(hass, mock_next_event):
event = copy.deepcopy(TEST_EVENT)
event["start"]["date"] = start
event["end"]["date"] = end
- event["summary"] = "{} !!-{}:0".format(event_summary, offset_hours)
+ event["summary"] = f"{event_summary} !!-{offset_hours}:0"
mock_next_event.return_value.event = event
assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG})
diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py
index c0b5aa7b193..802b7968ee6 100644
--- a/tests/components/google_assistant/__init__.py
+++ b/tests/components/google_assistant/__init__.py
@@ -92,7 +92,7 @@ DEMO_DEVICES = [
"id": "switch.ac",
"name": {"name": "AC"},
"traits": ["action.devices.traits.OnOff"],
- "type": "action.devices.types.SWITCH",
+ "type": "action.devices.types.OUTLET",
"willReportState": False,
},
{
diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py
index 3be97013e4d..c806e656762 100644
--- a/tests/components/google_assistant/test_google_assistant.py
+++ b/tests/components/google_assistant/test_google_assistant.py
@@ -31,7 +31,7 @@ ACCESS_TOKEN = "superdoublesecret"
@pytest.fixture
def auth_header(hass_access_token):
"""Generate an HTTP header with bearer token authorization."""
- return {AUTHORIZATION: "Bearer {}".format(hass_access_token)}
+ return {AUTHORIZATION: f"Bearer {hass_access_token}"}
@pytest.fixture
@@ -175,12 +175,12 @@ async def test_query_request(hass_fixture, assistant_client, auth_header):
assert devices["light.bed_light"]["on"] is False
assert devices["light.ceiling_lights"]["on"] is True
assert devices["light.ceiling_lights"]["brightness"] == 70
+ assert devices["light.ceiling_lights"]["color"]["temperatureK"] == 2631
assert devices["light.kitchen_lights"]["color"]["spectrumHsv"] == {
"hue": 345,
"saturation": 0.75,
"value": 0.7058823529411765,
}
- assert devices["light.kitchen_lights"]["color"]["temperatureK"] == 4166
assert devices["media_player.lounge_room"]["on"] is True
@@ -372,7 +372,6 @@ async def test_execute_request(hass_fixture, assistant_client, auth_header):
bed = hass_fixture.states.get("light.bed_light")
assert bed.attributes.get(light.ATTR_COLOR_TEMP) == 212
- assert bed.attributes.get(light.ATTR_RGB_COLOR) == (0, 255, 0)
assert hass_fixture.states.get("switch.decorative_lights").state == "off"
diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py
index f5e3e505a28..ff159e4e10c 100644
--- a/tests/components/google_assistant/test_http.py
+++ b/tests/components/google_assistant/test_http.py
@@ -27,7 +27,7 @@ MOCK_TOKEN = {"access_token": "dummtoken", "expires_in": 3600}
MOCK_JSON = {"devices": {}}
MOCK_URL = "https://dummy"
MOCK_HEADER = {
- "Authorization": "Bearer {}".format(MOCK_TOKEN["access_token"]),
+ "Authorization": f"Bearer {MOCK_TOKEN['access_token']}",
"X-GFE-SSL": "yes",
}
@@ -57,7 +57,7 @@ async def test_get_access_token(hass, aioclient_mock):
await _get_homegraph_token(hass, jwt)
assert aioclient_mock.call_count == 1
assert aioclient_mock.mock_calls[0][3] == {
- "Authorization": "Bearer {}".format(jwt),
+ "Authorization": f"Bearer {jwt}",
"Content-Type": "application/x-www-form-urlencoded",
}
diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py
index 7e98f162f22..c08c15a02f4 100644
--- a/tests/components/google_assistant/test_smart_home.py
+++ b/tests/components/google_assistant/test_smart_home.py
@@ -203,6 +203,11 @@ async def test_query_message(hass):
light2.entity_id = "light.another_light"
await light2.async_update_ha_state()
+ light3 = DemoLight(None, "Color temp Light", state=True, ct=400, brightness=200)
+ light3.hass = hass
+ light3.entity_id = "light.color_temp_light"
+ await light3.async_update_ha_state()
+
events = []
hass.bus.async_listen(EVENT_QUERY_RECEIVED, events.append)
@@ -219,6 +224,7 @@ async def test_query_message(hass):
"devices": [
{"id": "light.demo_light"},
{"id": "light.another_light"},
+ {"id": "light.color_temp_light"},
{"id": "light.non_existing"},
]
},
@@ -244,14 +250,19 @@ async def test_query_message(hass):
"saturation": 0.75,
"value": 0.3058823529411765,
},
- "temperatureK": 2500,
},
},
+ "light.color_temp_light": {
+ "on": True,
+ "online": True,
+ "brightness": 78,
+ "color": {"temperatureK": 2500},
+ },
}
},
}
- assert len(events) == 3
+ assert len(events) == 4
assert events[0].event_type == EVENT_QUERY_RECEIVED
assert events[0].data == {
"request_id": REQ_ID,
@@ -266,6 +277,12 @@ async def test_query_message(hass):
}
assert events[2].event_type == EVENT_QUERY_RECEIVED
assert events[2].data == {
+ "request_id": REQ_ID,
+ "entity_id": "light.color_temp_light",
+ "source": "cloud",
+ }
+ assert events[3].event_type == EVENT_QUERY_RECEIVED
+ assert events[3].data == {
"request_id": REQ_ID,
"entity_id": "light.non_existing",
"source": "cloud",
@@ -301,6 +318,7 @@ async def test_execute(hass):
"devices": [
{"id": "light.non_existing"},
{"id": "light.ceiling_lights"},
+ {"id": "light.kitchen_lights"},
],
"execution": [
{
@@ -321,6 +339,8 @@ async def test_execute(hass):
const.SOURCE_CLOUD,
)
+ print(result)
+
assert result == {
"requestId": REQ_ID,
"payload": {
@@ -333,17 +353,26 @@ async def test_execute(hass):
{
"ids": ["light.ceiling_lights"],
"status": "SUCCESS",
+ "states": {
+ "on": True,
+ "online": True,
+ "brightness": 20,
+ "color": {"temperatureK": 2631},
+ },
+ },
+ {
+ "ids": ["light.kitchen_lights"],
+ "status": "SUCCESS",
"states": {
"on": True,
"online": True,
"brightness": 20,
"color": {
"spectrumHsv": {
- "hue": 56,
- "saturation": 0.86,
+ "hue": 345,
+ "saturation": 0.75,
"value": 0.2,
},
- "temperatureK": 2631,
},
},
},
@@ -351,7 +380,7 @@ async def test_execute(hass):
},
}
- assert len(events) == 4
+ assert len(events) == 6
assert events[0].event_type == EVENT_COMMAND_RECEIVED
assert events[0].data == {
"request_id": REQ_ID,
@@ -392,21 +421,54 @@ async def test_execute(hass):
},
"source": "cloud",
}
+ assert events[4].event_type == EVENT_COMMAND_RECEIVED
+ assert events[4].data == {
+ "request_id": REQ_ID,
+ "entity_id": "light.kitchen_lights",
+ "execution": {
+ "command": "action.devices.commands.OnOff",
+ "params": {"on": True},
+ },
+ "source": "cloud",
+ }
+ assert events[5].event_type == EVENT_COMMAND_RECEIVED
+ assert events[5].data == {
+ "request_id": REQ_ID,
+ "entity_id": "light.kitchen_lights",
+ "execution": {
+ "command": "action.devices.commands.BrightnessAbsolute",
+ "params": {"brightness": 20},
+ },
+ "source": "cloud",
+ }
- assert len(service_events) == 2
+ assert len(service_events) == 4
assert service_events[0].data == {
"domain": "light",
"service": "turn_on",
"service_data": {"entity_id": "light.ceiling_lights"},
}
- assert service_events[0].context == events[2].context
assert service_events[1].data == {
"domain": "light",
"service": "turn_on",
"service_data": {"brightness_pct": 20, "entity_id": "light.ceiling_lights"},
}
+ assert service_events[0].context == events[2].context
assert service_events[1].context == events[2].context
assert service_events[1].context == events[3].context
+ assert service_events[2].data == {
+ "domain": "light",
+ "service": "turn_on",
+ "service_data": {"entity_id": "light.kitchen_lights"},
+ }
+ assert service_events[3].data == {
+ "domain": "light",
+ "service": "turn_on",
+ "service_data": {"brightness_pct": 20, "entity_id": "light.kitchen_lights"},
+ }
+ assert service_events[2].context == events[4].context
+ assert service_events[3].context == events[4].context
+ assert service_events[3].context == events[5].context
async def test_raising_error_trait(hass):
diff --git a/tests/components/google_domains/test_init.py b/tests/components/google_domains/test_init.py
index 66e334d342f..1ebc5cfda80 100644
--- a/tests/components/google_domains/test_init.py
+++ b/tests/components/google_domains/test_init.py
@@ -13,7 +13,7 @@ DOMAIN = "test.example.com"
USERNAME = "abc123"
PASSWORD = "xyz789"
-UPDATE_URL = google_domains.UPDATE_URL.format(USERNAME, PASSWORD)
+UPDATE_URL = f"https://{USERNAME}:{PASSWORD}@domains.google.com/nic/update"
@pytest.fixture
diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py
index 8a529f93f72..bddee724966 100644
--- a/tests/components/google_wifi/test_sensor.py
+++ b/tests/components/google_wifi/test_sensor.py
@@ -48,9 +48,7 @@ class TestGoogleWifiSetup(unittest.TestCase):
@requests_mock.Mocker()
def test_setup_minimum(self, mock_req):
"""Test setup with minimum configuration."""
- resource = "{}{}{}".format(
- "http://", google_wifi.DEFAULT_HOST, google_wifi.ENDPOINT
- )
+ resource = f"http://{google_wifi.DEFAULT_HOST}{google_wifi.ENDPOINT}"
mock_req.get(resource, status_code=200)
assert setup_component(
self.hass,
@@ -62,7 +60,7 @@ class TestGoogleWifiSetup(unittest.TestCase):
@requests_mock.Mocker()
def test_setup_get(self, mock_req):
"""Test setup with full configuration."""
- resource = "{}{}{}".format("http://", "localhost", google_wifi.ENDPOINT)
+ resource = f"http://localhost{google_wifi.ENDPOINT}"
mock_req.get(resource, status_code=200)
assert setup_component(
self.hass,
@@ -101,7 +99,7 @@ class TestGoogleWifiSensor(unittest.TestCase):
def setup_api(self, data, mock_req):
"""Set up API with fake data."""
- resource = "{}{}{}".format("http://", "localhost", google_wifi.ENDPOINT)
+ resource = f"http://localhost{google_wifi.ENDPOINT}"
now = datetime(1970, month=1, day=1)
with patch("homeassistant.util.dt.now", return_value=now):
mock_req.get(resource, text=data, status_code=200)
@@ -111,7 +109,7 @@ class TestGoogleWifiSensor(unittest.TestCase):
self.sensor_dict = dict()
for condition, cond_list in google_wifi.MONITORED_CONDITIONS.items():
sensor = google_wifi.GoogleWifiSensor(self.api, self.name, condition)
- name = "{}_{}".format(self.name, condition)
+ name = f"{self.name}_{condition}"
units = cond_list[1]
icon = cond_list[2]
self.sensor_dict[condition] = {
diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py
index f81ef45a648..9135f583d19 100644
--- a/tests/components/gpslogger/test_init.py
+++ b/tests/components/gpslogger/test_init.py
@@ -77,7 +77,7 @@ async def webhook_id(hass, gpslogger_client):
async def test_missing_data(hass, gpslogger_client, webhook_id):
"""Test missing data."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
data = {"latitude": 1.0, "longitude": 1.1, "device": "123"}
@@ -103,7 +103,7 @@ async def test_missing_data(hass, gpslogger_client, webhook_id):
async def test_enter_and_exit(hass, gpslogger_client, webhook_id):
"""Test when there is a known zone."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
data = {"latitude": HOME_LATITUDE, "longitude": HOME_LONGITUDE, "device": "123"}
@@ -111,18 +111,14 @@ async def test_enter_and_exit(hass, gpslogger_client, webhook_id):
req = await gpslogger_client.post(url, data=data)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- state_name = hass.states.get(
- "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"])
- ).state
+ state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state
assert STATE_HOME == state_name
# Enter Home again
req = await gpslogger_client.post(url, data=data)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- state_name = hass.states.get(
- "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"])
- ).state
+ state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state
assert STATE_HOME == state_name
data["longitude"] = 0
@@ -132,9 +128,7 @@ async def test_enter_and_exit(hass, gpslogger_client, webhook_id):
req = await gpslogger_client.post(url, data=data)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- state_name = hass.states.get(
- "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"])
- ).state
+ state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state
assert STATE_NOT_HOME == state_name
dev_reg = await hass.helpers.device_registry.async_get_registry()
@@ -146,7 +140,7 @@ async def test_enter_and_exit(hass, gpslogger_client, webhook_id):
async def test_enter_with_attrs(hass, gpslogger_client, webhook_id):
"""Test when additional attributes are present."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
data = {
"latitude": 1.0,
@@ -164,7 +158,7 @@ async def test_enter_with_attrs(hass, gpslogger_client, webhook_id):
req = await gpslogger_client.post(url, data=data)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"]))
+ state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}")
assert state.state == STATE_NOT_HOME
assert state.attributes["gps_accuracy"] == 10.5
assert state.attributes["battery_level"] == 10.0
@@ -190,7 +184,7 @@ async def test_enter_with_attrs(hass, gpslogger_client, webhook_id):
req = await gpslogger_client.post(url, data=data)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"]))
+ state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}")
assert state.state == STATE_HOME
assert state.attributes["gps_accuracy"] == 123
assert state.attributes["battery_level"] == 23
@@ -206,16 +200,14 @@ async def test_enter_with_attrs(hass, gpslogger_client, webhook_id):
)
async def test_load_unload_entry(hass, gpslogger_client, webhook_id):
"""Test that the appropriate dispatch signals are added and removed."""
- url = "/api/webhook/{}".format(webhook_id)
+ url = f"/api/webhook/{webhook_id}"
data = {"latitude": HOME_LATITUDE, "longitude": HOME_LONGITUDE, "device": "123"}
# Enter the Home
req = await gpslogger_client.post(url, data=data)
await hass.async_block_till_done()
assert req.status == HTTP_OK
- state_name = hass.states.get(
- "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"])
- ).state
+ state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state
assert STATE_HOME == state_name
assert len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) == 1
diff --git a/tests/components/griddy/__init__.py b/tests/components/griddy/__init__.py
new file mode 100644
index 00000000000..415ddc3ba5c
--- /dev/null
+++ b/tests/components/griddy/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Griddy Power integration."""
diff --git a/tests/components/griddy/test_config_flow.py b/tests/components/griddy/test_config_flow.py
new file mode 100644
index 00000000000..1ab29aebece
--- /dev/null
+++ b/tests/components/griddy/test_config_flow.py
@@ -0,0 +1,54 @@
+"""Test the Griddy Power config flow."""
+import asyncio
+
+from asynctest import MagicMock, patch
+
+from homeassistant import config_entries, setup
+from homeassistant.components.griddy.const import DOMAIN
+
+
+async def test_form(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch(
+ "homeassistant.components.griddy.config_flow.AsyncGriddy.async_getnow",
+ return_value=MagicMock(),
+ ), patch(
+ "homeassistant.components.griddy.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.griddy.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"loadzone": "LZ_HOUSTON"},
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "Load Zone LZ_HOUSTON"
+ assert result2["data"] == {"loadzone": "LZ_HOUSTON"}
+ 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.griddy.config_flow.AsyncGriddy.async_getnow",
+ side_effect=asyncio.TimeoutError,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"loadzone": "LZ_NORTH"},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
diff --git a/tests/components/griddy/test_sensor.py b/tests/components/griddy/test_sensor.py
new file mode 100644
index 00000000000..995327a9b56
--- /dev/null
+++ b/tests/components/griddy/test_sensor.py
@@ -0,0 +1,39 @@
+"""The sensor tests for the griddy platform."""
+import json
+import os
+
+from asynctest import patch
+from griddypower.async_api import GriddyPriceData
+
+from homeassistant.components.griddy import CONF_LOADZONE, DOMAIN
+from homeassistant.setup import async_setup_component
+
+from tests.common import load_fixture
+
+
+async def _load_json_fixture(hass, path):
+ fixture = await hass.async_add_executor_job(
+ load_fixture, os.path.join("griddy", path)
+ )
+ return json.loads(fixture)
+
+
+def _mock_get_config():
+ """Return a default griddy config."""
+ return {DOMAIN: {CONF_LOADZONE: "LZ_HOUSTON"}}
+
+
+async def test_houston_loadzone(hass):
+ """Test creation of the houston load zone."""
+
+ getnow_json = await _load_json_fixture(hass, "getnow.json")
+ griddy_price_data = GriddyPriceData(getnow_json)
+ with patch(
+ "homeassistant.components.griddy.AsyncGriddy.async_getnow",
+ return_value=griddy_price_data,
+ ):
+ assert await async_setup_component(hass, DOMAIN, _mock_get_config())
+ await hass.async_block_till_done()
+
+ sensor_lz_houston_price_now = hass.states.get("sensor.lz_houston_price_now")
+ assert sensor_lz_houston_price_now.state == "1.269"
diff --git a/tests/components/group/common.py b/tests/components/group/common.py
index 9d86b41d77a..69de1cfee75 100644
--- a/tests/components/group/common.py
+++ b/tests/components/group/common.py
@@ -5,17 +5,13 @@ components. Instead call the service directly.
"""
from homeassistant.components.group import (
ATTR_ADD_ENTITIES,
- ATTR_CONTROL,
ATTR_ENTITIES,
ATTR_OBJECT_ID,
- ATTR_VIEW,
- ATTR_VISIBLE,
DOMAIN,
SERVICE_REMOVE,
SERVICE_SET,
- SERVICE_SET_VISIBILITY,
)
-from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, ATTR_NAME, SERVICE_RELOAD
+from homeassistant.const import ATTR_ICON, ATTR_NAME, SERVICE_RELOAD
from homeassistant.core import callback
from homeassistant.loader import bind_hass
@@ -35,43 +31,18 @@ def async_reload(hass):
@bind_hass
def set_group(
- hass,
- object_id,
- name=None,
- entity_ids=None,
- visible=None,
- icon=None,
- view=None,
- control=None,
- add=None,
+ hass, object_id, name=None, entity_ids=None, icon=None, add=None,
):
"""Create/Update a group."""
hass.add_job(
- async_set_group,
- hass,
- object_id,
- name,
- entity_ids,
- visible,
- icon,
- view,
- control,
- add,
+ async_set_group, hass, object_id, name, entity_ids, icon, add,
)
@callback
@bind_hass
def async_set_group(
- hass,
- object_id,
- name=None,
- entity_ids=None,
- visible=None,
- icon=None,
- view=None,
- control=None,
- add=None,
+ hass, object_id, name=None, entity_ids=None, icon=None, add=None,
):
"""Create/Update a group."""
data = {
@@ -80,10 +51,7 @@ def async_set_group(
(ATTR_OBJECT_ID, object_id),
(ATTR_NAME, name),
(ATTR_ENTITIES, entity_ids),
- (ATTR_VISIBLE, visible),
(ATTR_ICON, icon),
- (ATTR_VIEW, view),
- (ATTR_CONTROL, control),
(ATTR_ADD_ENTITIES, add),
]
if value is not None
@@ -98,10 +66,3 @@ def async_remove(hass, object_id):
"""Remove a user group."""
data = {ATTR_OBJECT_ID: object_id}
hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_REMOVE, data))
-
-
-@bind_hass
-def set_visibility(hass, entity_id=None, visible=True):
- """Hide or shows a group."""
- data = {ATTR_ENTITY_ID: entity_id, ATTR_VISIBLE: visible}
- hass.services.call(DOMAIN, SERVICE_SET_VISIBILITY, data)
diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py
index febe261c9e4..e8878b7cf4a 100644
--- a/tests/components/group/test_init.py
+++ b/tests/components/group/test_init.py
@@ -8,7 +8,6 @@ import homeassistant.components.group as group
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_FRIENDLY_NAME,
- ATTR_HIDDEN,
ATTR_ICON,
STATE_HOME,
STATE_NOT_HOME,
@@ -44,10 +43,7 @@ class TestComponentsGroup(unittest.TestCase):
)
assert (
- STATE_ON
- == self.hass.states.get(
- group.ENTITY_ID_FORMAT.format("person_and_light")
- ).state
+ STATE_ON == self.hass.states.get(f"{group.DOMAIN}.person_and_light").state
)
def test_setup_group_with_a_non_existing_state(self):
@@ -291,38 +287,28 @@ class TestComponentsGroup(unittest.TestCase):
group_conf["second_group"] = {
"entities": "light.Bowl, " + test_group.entity_id,
"icon": "mdi:work",
- "view": True,
- "control": "hidden",
}
group_conf["test_group"] = "hello.world,sensor.happy"
group_conf["empty_group"] = {"name": "Empty Group", "entities": None}
setup_component(self.hass, "group", {"group": group_conf})
- group_state = self.hass.states.get(
- group.ENTITY_ID_FORMAT.format("second_group")
- )
+ group_state = self.hass.states.get(f"{group.DOMAIN}.second_group")
assert STATE_ON == group_state.state
assert set((test_group.entity_id, "light.bowl")) == set(
group_state.attributes["entity_id"]
)
assert group_state.attributes.get(group.ATTR_AUTO) is None
assert "mdi:work" == group_state.attributes.get(ATTR_ICON)
- assert group_state.attributes.get(group.ATTR_VIEW)
- assert "hidden" == group_state.attributes.get(group.ATTR_CONTROL)
- assert group_state.attributes.get(ATTR_HIDDEN)
assert 1 == group_state.attributes.get(group.ATTR_ORDER)
- group_state = self.hass.states.get(group.ENTITY_ID_FORMAT.format("test_group"))
+ group_state = self.hass.states.get(f"{group.DOMAIN}.test_group")
assert STATE_UNKNOWN == group_state.state
assert set(("sensor.happy", "hello.world")) == set(
group_state.attributes["entity_id"]
)
assert group_state.attributes.get(group.ATTR_AUTO) is None
assert group_state.attributes.get(ATTR_ICON) is None
- assert group_state.attributes.get(group.ATTR_VIEW) is None
- assert group_state.attributes.get(group.ATTR_CONTROL) is None
- assert group_state.attributes.get(ATTR_HIDDEN) is None
assert 2 == group_state.attributes.get(group.ATTR_ORDER)
def test_groups_get_unique_names(self):
@@ -382,10 +368,7 @@ class TestComponentsGroup(unittest.TestCase):
)
self.hass.states.set("device_tracker.Adam", "cool_state_not_home")
self.hass.block_till_done()
- assert (
- STATE_NOT_HOME
- == self.hass.states.get(group.ENTITY_ID_FORMAT.format("peeps")).state
- )
+ assert STATE_NOT_HOME == self.hass.states.get(f"{group.DOMAIN}.peeps").state
def test_reloading_groups(self):
"""Test reloading the group config."""
@@ -394,11 +377,7 @@ class TestComponentsGroup(unittest.TestCase):
"group",
{
"group": {
- "second_group": {
- "entities": "light.Bowl",
- "icon": "mdi:work",
- "view": True,
- },
+ "second_group": {"entities": "light.Bowl", "icon": "mdi:work"},
"test_group": "hello.world,sensor.happy",
"empty_group": {"name": "Empty Group", "entities": None},
}
@@ -420,13 +399,7 @@ class TestComponentsGroup(unittest.TestCase):
with patch(
"homeassistant.config.load_yaml_config_file",
return_value={
- "group": {
- "hello": {
- "entities": "light.Bowl",
- "icon": "mdi:work",
- "view": True,
- }
- }
+ "group": {"hello": {"entities": "light.Bowl", "icon": "mdi:work"}}
},
):
common.reload(self.hass)
@@ -438,26 +411,6 @@ class TestComponentsGroup(unittest.TestCase):
]
assert self.hass.bus.listeners["state_changed"] == 2
- def test_changing_group_visibility(self):
- """Test that a group can be hidden and shown."""
- assert setup_component(
- self.hass, "group", {"group": {"test_group": "hello.world,sensor.happy"}}
- )
-
- group_entity_id = group.ENTITY_ID_FORMAT.format("test_group")
-
- # Hide the group
- common.set_visibility(self.hass, group_entity_id, False)
- self.hass.block_till_done()
- group_state = self.hass.states.get(group_entity_id)
- assert group_state.attributes.get(ATTR_HIDDEN)
-
- # Show it again
- common.set_visibility(self.hass, group_entity_id, True)
- self.hass.block_till_done()
- group_state = self.hass.states.get(group_entity_id)
- assert group_state.attributes.get(ATTR_HIDDEN) is None
-
def test_modify_group(self):
"""Test modifying a group."""
group_conf = OrderedDict()
@@ -470,9 +423,7 @@ class TestComponentsGroup(unittest.TestCase):
common.set_group(self.hass, "modify_group", icon="mdi:play")
self.hass.block_till_done()
- group_state = self.hass.states.get(
- group.ENTITY_ID_FORMAT.format("modify_group")
- )
+ group_state = self.hass.states.get(f"{group.DOMAIN}.modify_group")
assert self.hass.states.entity_ids() == ["group.modify_group"]
assert group_state.attributes.get(ATTR_ICON) == "mdi:play"
@@ -502,20 +453,12 @@ async def test_service_group_set_group_remove_group(hass):
assert group_state.attributes[group.ATTR_AUTO]
assert group_state.attributes["friendly_name"] == "Test"
- common.async_set_group(
- hass,
- "user_test_group",
- view=True,
- visible=False,
- entity_ids=["test.entity_bla1"],
- )
+ common.async_set_group(hass, "user_test_group", entity_ids=["test.entity_bla1"])
await hass.async_block_till_done()
group_state = hass.states.get("group.user_test_group")
assert group_state
- assert group_state.attributes[group.ATTR_VIEW]
assert group_state.attributes[group.ATTR_AUTO]
- assert group_state.attributes["hidden"]
assert group_state.attributes["friendly_name"] == "Test"
assert list(group_state.attributes["entity_id"]) == ["test.entity_bla1"]
@@ -524,19 +467,15 @@ async def test_service_group_set_group_remove_group(hass):
"user_test_group",
icon="mdi:camera",
name="Test2",
- control="hidden",
add=["test.entity_id2"],
)
await hass.async_block_till_done()
group_state = hass.states.get("group.user_test_group")
assert group_state
- assert group_state.attributes[group.ATTR_VIEW]
assert group_state.attributes[group.ATTR_AUTO]
- assert group_state.attributes["hidden"]
assert group_state.attributes["friendly_name"] == "Test2"
assert group_state.attributes["icon"] == "mdi:camera"
- assert group_state.attributes[group.ATTR_CONTROL] == "hidden"
assert sorted(list(group_state.attributes["entity_id"])) == sorted(
["test.entity_bla1", "test.entity_id2"]
)
diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py
index 87898e42d59..1f1466981b6 100644
--- a/tests/components/group/test_light.py
+++ b/tests/components/group/test_light.py
@@ -229,7 +229,7 @@ async def test_emulated_color_temp_group(hass):
state = hass.states.get("light.ceiling_lights")
assert state.state == "on"
assert state.attributes["color_temp"] == 200
- assert "hs_color" in state.attributes.keys()
+ assert "hs_color" not in state.attributes.keys()
state = hass.states.get("light.kitchen_lights")
assert state.state == "on"
diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py
index 84e5dce1f1c..b83923943bd 100644
--- a/tests/components/heos/test_config_flow.py
+++ b/tests/components/heos/test_config_flow.py
@@ -1,12 +1,13 @@
"""Tests for the Heos config flow module."""
from urllib.parse import urlparse
+from asynctest import patch
from pyheos import HeosError
from homeassistant import data_entry_flow
-from homeassistant.components import ssdp
+from homeassistant.components import heos, ssdp
from homeassistant.components.heos.config_flow import HeosFlowHandler
-from homeassistant.components.heos.const import DATA_DISCOVERED_HOSTS, DOMAIN
+from homeassistant.components.heos.const import DATA_DISCOVERED_HOSTS
from homeassistant.const import CONF_HOST
@@ -32,10 +33,10 @@ async def test_no_host_shows_form(hass):
async def test_cannot_connect_shows_error_form(hass, controller):
"""Test form is shown with error when cannot connect."""
- flow = HeosFlowHandler()
- flow.hass = hass
controller.connect.side_effect = HeosError()
- result = await flow.async_step_user({CONF_HOST: "127.0.0.1"})
+ result = await hass.config_entries.flow.async_init(
+ heos.DOMAIN, context={"source": "user"}, data={CONF_HOST: "127.0.0.1"}
+ )
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"][CONF_HOST] == "connection_failure"
@@ -47,36 +48,38 @@ async def test_cannot_connect_shows_error_form(hass, controller):
async def test_create_entry_when_host_valid(hass, controller):
"""Test result type is create entry when host is valid."""
- flow = HeosFlowHandler()
- flow.hass = hass
data = {CONF_HOST: "127.0.0.1"}
- result = await flow.async_step_user(data)
- assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == "Controller (127.0.0.1)"
- assert result["data"] == data
- assert controller.connect.call_count == 1
- assert controller.disconnect.call_count == 1
+ with patch("homeassistant.components.heos.async_setup_entry", return_value=True):
+ result = await hass.config_entries.flow.async_init(
+ heos.DOMAIN, context={"source": "user"}, data=data
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == "Controller (127.0.0.1)"
+ assert result["data"] == data
+ assert controller.connect.call_count == 1
+ assert controller.disconnect.call_count == 1
async def test_create_entry_when_friendly_name_valid(hass, controller):
"""Test result type is create entry when friendly name is valid."""
hass.data[DATA_DISCOVERED_HOSTS] = {"Office (127.0.0.1)": "127.0.0.1"}
- flow = HeosFlowHandler()
- flow.hass = hass
data = {CONF_HOST: "Office (127.0.0.1)"}
- result = await flow.async_step_user(data)
- assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
- assert result["title"] == "Controller (127.0.0.1)"
- assert result["data"] == {CONF_HOST: "127.0.0.1"}
- assert controller.connect.call_count == 1
- assert controller.disconnect.call_count == 1
- assert DATA_DISCOVERED_HOSTS not in hass.data
+ with patch("homeassistant.components.heos.async_setup_entry", return_value=True):
+ result = await hass.config_entries.flow.async_init(
+ heos.DOMAIN, context={"source": "user"}, data=data
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == "Controller (127.0.0.1)"
+ assert result["data"] == {CONF_HOST: "127.0.0.1"}
+ assert controller.connect.call_count == 1
+ assert controller.disconnect.call_count == 1
+ assert DATA_DISCOVERED_HOSTS not in hass.data
async def test_discovery_shows_create_form(hass, controller, discovery_data):
"""Test discovery shows form to confirm setup and subsequent abort."""
await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": "ssdp"}, data=discovery_data
+ heos.DOMAIN, context={"source": "ssdp"}, data=discovery_data
)
await hass.async_block_till_done()
assert len(hass.config_entries.flow.async_progress()) == 1
@@ -86,7 +89,7 @@ async def test_discovery_shows_create_form(hass, controller, discovery_data):
discovery_data[ssdp.ATTR_SSDP_LOCATION] = f"http://127.0.0.2:{port}/"
discovery_data[ssdp.ATTR_UPNP_FRIENDLY_NAME] = "Bedroom"
await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": "ssdp"}, data=discovery_data
+ heos.DOMAIN, context={"source": "ssdp"}, data=discovery_data
)
await hass.async_block_till_done()
assert len(hass.config_entries.flow.async_progress()) == 1
diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py
index 4456b256f6e..fcae8bd1f8c 100644
--- a/tests/components/here_travel_time/test_sensor.py
+++ b/tests/components/here_travel_time/test_sensor.py
@@ -28,6 +28,7 @@ from homeassistant.components.here_travel_time.sensor import (
ROUTE_MODE_FASTEST,
ROUTE_MODE_SHORTEST,
SCAN_INTERVAL,
+ TIME_MINUTES,
TRAFFIC_MODE_DISABLED,
TRAFFIC_MODE_ENABLED,
TRAVEL_MODE_BICYCLE,
@@ -36,7 +37,6 @@ from homeassistant.components.here_travel_time.sensor import (
TRAVEL_MODE_PUBLIC,
TRAVEL_MODE_PUBLIC_TIME_TABLE,
TRAVEL_MODE_TRUCK,
- UNIT_OF_MEASUREMENT,
)
from homeassistant.const import ATTR_ICON, EVENT_HOMEASSISTANT_START
from homeassistant.setup import async_setup_component
@@ -83,7 +83,7 @@ def _build_mock_url(origin, destination, modes, api_key, departure):
def _assert_truck_sensor(sensor):
"""Assert that states and attributes are correct for truck_response."""
assert sensor.state == "14"
- assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT
+ assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES
assert sensor.attributes.get(ATTR_ATTRIBUTION) is None
assert sensor.attributes.get(ATTR_DURATION) == 13.533333333333333
@@ -177,7 +177,7 @@ async def test_car(hass, requests_mock_car_disabled_response):
sensor = hass.states.get("sensor.test")
assert sensor.state == "30"
- assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT
+ assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES
assert sensor.attributes.get(ATTR_ATTRIBUTION) is None
assert sensor.attributes.get(ATTR_DURATION) == 30.05
assert sensor.attributes.get(ATTR_DISTANCE) == 23.903
@@ -381,7 +381,7 @@ async def test_public_transport(hass, requests_mock_credentials_check):
sensor = hass.states.get("sensor.test")
assert sensor.state == "89"
- assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT
+ assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES
assert sensor.attributes.get(ATTR_ATTRIBUTION) is None
assert sensor.attributes.get(ATTR_DURATION) == 89.16666666666667
@@ -431,7 +431,7 @@ async def test_public_transport_time_table(hass, requests_mock_credentials_check
sensor = hass.states.get("sensor.test")
assert sensor.state == "80"
- assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT
+ assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES
assert sensor.attributes.get(ATTR_ATTRIBUTION) is None
assert sensor.attributes.get(ATTR_DURATION) == 79.73333333333333
@@ -481,7 +481,7 @@ async def test_pedestrian(hass, requests_mock_credentials_check):
sensor = hass.states.get("sensor.test")
assert sensor.state == "211"
- assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT
+ assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES
assert sensor.attributes.get(ATTR_ATTRIBUTION) is None
assert sensor.attributes.get(ATTR_DURATION) == 210.51666666666668
@@ -532,7 +532,7 @@ async def test_bicycle(hass, requests_mock_credentials_check):
sensor = hass.states.get("sensor.test")
assert sensor.state == "55"
- assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT
+ assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES
assert sensor.attributes.get(ATTR_ATTRIBUTION) is None
assert sensor.attributes.get(ATTR_DURATION) == 54.86666666666667
diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py
index 051024999e4..65c0a717bee 100644
--- a/tests/components/history/test_init.py
+++ b/tests/components/history/test_init.py
@@ -14,6 +14,7 @@ from tests.common import (
init_recorder_component,
mock_state_change_event,
)
+from tests.components.recorder.common import wait_recording_done
class TestComponentHistory(unittest.TestCase):
@@ -31,12 +32,7 @@ class TestComponentHistory(unittest.TestCase):
"""Initialize the recorder."""
init_recorder_component(self.hass)
self.hass.start()
- self.wait_recording_done()
-
- def wait_recording_done(self):
- """Block till recording is done."""
- self.hass.block_till_done()
- self.hass.data[recorder.DATA_INSTANCE].block_till_done()
+ wait_recording_done(self.hass)
def test_setup(self):
"""Test setup method of history."""
@@ -78,7 +74,7 @@ class TestComponentHistory(unittest.TestCase):
states.append(state)
- self.wait_recording_done()
+ wait_recording_done(self.hass)
future = now + timedelta(seconds=1)
with patch(
@@ -93,7 +89,7 @@ class TestComponentHistory(unittest.TestCase):
mock_state_change_event(self.hass, state)
- self.wait_recording_done()
+ wait_recording_done(self.hass)
# Get states returns everything before POINT
for state1, state2 in zip(
@@ -115,7 +111,7 @@ class TestComponentHistory(unittest.TestCase):
def set_state(state):
"""Set the state."""
self.hass.states.set(entity_id, state)
- self.wait_recording_done()
+ wait_recording_done(self.hass)
return self.hass.states.get(entity_id)
start = dt_util.utcnow()
@@ -156,7 +152,7 @@ class TestComponentHistory(unittest.TestCase):
def set_state(state):
"""Set the state."""
self.hass.states.set(entity_id, state)
- self.wait_recording_done()
+ wait_recording_done(self.hass)
return self.hass.states.get(entity_id)
start = dt_util.utcnow() - timedelta(minutes=2)
@@ -559,7 +555,7 @@ class TestComponentHistory(unittest.TestCase):
def set_state(entity_id, state, **kwargs):
"""Set the state."""
self.hass.states.set(entity_id, state, **kwargs)
- self.wait_recording_done()
+ wait_recording_done(self.hass)
return self.hass.states.get(entity_id)
zero = dt_util.utcnow()
@@ -615,6 +611,7 @@ class TestComponentHistory(unittest.TestCase):
)
# state will be skipped since entity is hidden
set_state(therm, 22, attributes={"current_temperature": 21, "hidden": True})
+
return zero, four, states
diff --git a/tests/components/history_graph/__init__.py b/tests/components/history_graph/__init__.py
deleted file mode 100644
index 2cb34499938..00000000000
--- a/tests/components/history_graph/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Tests for the history_graph component."""
diff --git a/tests/components/history_graph/test_init.py b/tests/components/history_graph/test_init.py
deleted file mode 100644
index ef41f70aaa7..00000000000
--- a/tests/components/history_graph/test_init.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""The tests the Graph component."""
-
-import unittest
-
-from homeassistant.setup import setup_component
-
-from tests.common import get_test_home_assistant, init_recorder_component
-
-
-class TestGraph(unittest.TestCase):
- """Test the Google component."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Set up things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_setup_component(self):
- """Test setup component."""
- self.init_recorder()
- config = {"history": {}, "history_graph": {"name_1": {"entities": "test.test"}}}
-
- assert setup_component(self.hass, "history_graph", config)
- assert dict(self.hass.states.get("history_graph.name_1").attributes) == {
- "entity_id": ["test.test"],
- "friendly_name": "name_1",
- "hours_to_show": 24,
- "refresh": 0,
- }
-
- def init_recorder(self):
- """Initialize the recorder."""
- init_recorder_component(self.hass)
- self.hass.start()
diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py
index e6bf185c93b..08a04d5b88e 100644
--- a/tests/components/homekit/test_get_accessories.py
+++ b/tests/components/homekit/test_get_accessories.py
@@ -26,6 +26,7 @@ from homeassistant.const import (
CONF_TYPE,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
)
from homeassistant.core import State
@@ -180,7 +181,7 @@ def test_type_media_player(type_name, entity_id, state, attrs, config):
"HumiditySensor",
"sensor.humidity",
"20",
- {ATTR_DEVICE_CLASS: "humidity", ATTR_UNIT_OF_MEASUREMENT: "%"},
+ {ATTR_DEVICE_CLASS: "humidity", ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE},
),
("LightSensor", "sensor.light", "900", {ATTR_DEVICE_CLASS: "illuminance"}),
("LightSensor", "sensor.light", "900", {ATTR_UNIT_OF_MEASUREMENT: "lm"}),
diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py
index f357702040b..8834f730bce 100644
--- a/tests/components/homekit/test_type_lights.py
+++ b/tests/components/homekit/test_type_lights.py
@@ -21,6 +21,7 @@ from homeassistant.const import (
STATE_OFF,
STATE_ON,
STATE_UNKNOWN,
+ UNIT_PERCENTAGE,
)
from homeassistant.core import CoreState
from homeassistant.helpers import entity_registry
@@ -127,7 +128,7 @@ async def test_light_brightness(hass, hk_driver, cls, events):
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] == "brightness at 20%"
+ assert events[-1].data[ATTR_VALUE] == f"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)
@@ -136,7 +137,7 @@ async def test_light_brightness(hass, hk_driver, cls, events):
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] == "brightness at 40%"
+ assert events[-1].data[ATTR_VALUE] == f"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)
@@ -235,9 +236,7 @@ async def test_light_restore(hass, hk_driver, cls, events):
registry = await entity_registry.async_get_registry(hass)
- registry.async_get_or_create(
- "light", "hue", "1234", suggested_object_id="simple",
- )
+ registry.async_get_or_create("light", "hue", "1234", suggested_object_id="simple")
registry.async_get_or_create(
"light",
"hue",
diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py
index 969ea0bddc8..79d807b77c5 100644
--- a/tests/components/homekit/test_type_sensors.py
+++ b/tests/components/homekit/test_type_sensors.py
@@ -26,6 +26,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
)
from homeassistant.core import CoreState
from homeassistant.helpers import entity_registry
@@ -287,7 +288,7 @@ async def test_sensor_restore(hass, hk_driver, events):
"12345",
suggested_object_id="humidity",
device_class="humidity",
- unit_of_measurement="%",
+ unit_of_measurement=UNIT_PERCENTAGE,
)
hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {})
await hass.async_block_till_done()
diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py
index 025c8c565f2..a1b5f37324d 100644
--- a/tests/components/homekit_controller/common.py
+++ b/tests/components/homekit_controller/common.py
@@ -4,14 +4,10 @@ import json
import os
from unittest import mock
-from homekit.exceptions import AccessoryNotFoundError
-from homekit.model import Accessory, get_id
-from homekit.model.characteristics import (
- AbstractCharacteristic,
- CharacteristicPermissions,
- CharacteristicsTypes,
-)
-from homekit.model.services import AbstractService, ServicesTypes
+from aiohomekit.model import Accessories, Accessory
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+from aiohomekit.testing import FakeController
from homeassistant import config_entries
from homeassistant.components.homekit_controller import config_flow
@@ -26,77 +22,6 @@ import homeassistant.util.dt as dt_util
from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture
-class FakePairing:
- """
- A test fake that pretends to be a paired HomeKit accessory.
-
- This only contains methods and values that exist on the upstream Pairing
- class.
- """
-
- def __init__(self, accessories):
- """Create a fake pairing from an accessory model."""
- self.accessories = accessories
- self.pairing_data = {}
- self.available = True
-
- def list_accessories_and_characteristics(self):
- """Fake implementation of list_accessories_and_characteristics."""
- accessories = [a.to_accessory_and_service_list() for a in self.accessories]
- # replicate what happens upstream right now
- self.pairing_data["accessories"] = accessories
- return accessories
-
- def get_characteristics(self, characteristics):
- """Fake implementation of get_characteristics."""
- if not self.available:
- raise AccessoryNotFoundError("Accessory not found")
-
- results = {}
- for aid, cid in characteristics:
- for accessory in self.accessories:
- if aid != accessory.aid:
- continue
- for service in accessory.services:
- for char in service.characteristics:
- if char.iid != cid:
- continue
- results[(aid, cid)] = {"value": char.get_value()}
- return results
-
- def put_characteristics(self, characteristics):
- """Fake implementation of put_characteristics."""
- for aid, cid, new_val in characteristics:
- for accessory in self.accessories:
- if aid != accessory.aid:
- continue
- for service in accessory.services:
- for char in service.characteristics:
- if char.iid != cid:
- continue
- char.set_value(new_val)
- return {}
-
-
-class FakeController:
- """
- A test fake that pretends to be a paired HomeKit accessory.
-
- This only contains methods and values that exist on the upstream Controller
- class.
- """
-
- def __init__(self):
- """Create a Fake controller with no pairings."""
- self.pairings = {}
-
- def add(self, accessories):
- """Create and register a fake pairing for a simulated accessory."""
- pairing = FakePairing(accessories)
- self.pairings["00:00:00:00:00:00"] = pairing
- return pairing
-
-
class Helper:
"""Helper methods for interacting with HomeKit fakes."""
@@ -115,6 +40,11 @@ class Helper:
char_name = CharacteristicsTypes.get_short(char.type)
self.characteristics[(service_name, char_name)] = char
+ async def update_named_service(self, service, characteristics):
+ """Update a service."""
+ self.pairing.testing.update_named_service(service, characteristics)
+ await self.hass.async_block_till_done()
+
async def poll_and_get_state(self):
"""Trigger a time based poll and return the current entity state."""
await time_changed(self.hass, 60)
@@ -124,45 +54,6 @@ class Helper:
return state
-class FakeCharacteristic(AbstractCharacteristic):
- """
- A model of a generic HomeKit characteristic.
-
- Base is abstract and can't be instanced directly so this subclass is
- needed even though it doesn't add any methods.
- """
-
- def to_accessory_and_service_list(self):
- """Serialize the characteristic."""
- # Upstream doesn't correctly serialize valid_values
- # This fix will be upstreamed and this function removed when it
- # is fixed.
- record = super().to_accessory_and_service_list()
- if self.valid_values:
- record["valid-values"] = self.valid_values
- return record
-
-
-class FakeService(AbstractService):
- """A model of a generic HomeKit service."""
-
- def __init__(self, service_name):
- """Create a fake service by its short form HAP spec name."""
- char_type = ServicesTypes.get_uuid(service_name)
- super().__init__(char_type, get_id())
-
- def add_characteristic(self, name):
- """Add a characteristic to this service by name."""
- full_name = "public.hap.characteristic." + name
- char = FakeCharacteristic(get_id(), full_name, None)
- char.perms = [
- CharacteristicPermissions.paired_read,
- CharacteristicPermissions.paired_write,
- ]
- self.characteristics.append(char)
- return char
-
-
async def time_changed(hass, seconds):
"""Trigger time changed."""
next_update = dt_util.utcnow() + timedelta(seconds)
@@ -176,40 +67,7 @@ async def setup_accessories_from_file(hass, path):
load_fixture, os.path.join("homekit_controller", path)
)
accessories_json = json.loads(accessories_fixture)
-
- accessories = []
-
- for accessory_data in accessories_json:
- accessory = Accessory("Name", "Mfr", "Model", "0001", "0.1")
- accessory.services = []
- accessory.aid = accessory_data["aid"]
- for service_data in accessory_data["services"]:
- service = FakeService("public.hap.service.accessory-information")
- service.type = service_data["type"]
- service.iid = service_data["iid"]
-
- for char_data in service_data["characteristics"]:
- char = FakeCharacteristic(1, "23", None)
- char.type = char_data["type"]
- char.iid = char_data["iid"]
- char.perms = char_data["perms"]
- char.format = char_data["format"]
- if "description" in char_data:
- char.description = char_data["description"]
- if "value" in char_data:
- char.value = char_data["value"]
- if "minValue" in char_data:
- char.minValue = char_data["minValue"]
- if "maxValue" in char_data:
- char.maxValue = char_data["maxValue"]
- if "valid-values" in char_data:
- char.valid_values = char_data["valid-values"]
- service.characteristics.append(char)
-
- accessory.services.append(service)
-
- accessories.append(accessory)
-
+ accessories = Accessories.from_list(accessories_json)
return accessories
@@ -217,7 +75,7 @@ async def setup_platform(hass):
"""Load the platform but with a fake Controller API."""
config = {"discovery": {}}
- with mock.patch("homekit.Controller") as controller:
+ with mock.patch("aiohomekit.Controller") as controller:
fake_controller = controller.return_value = FakeController()
await async_setup_component(hass, DOMAIN, config)
@@ -227,35 +85,26 @@ async def setup_platform(hass):
async def setup_test_accessories(hass, accessories):
"""Load a fake homekit device based on captured JSON profile."""
fake_controller = await setup_platform(hass)
- pairing = fake_controller.add(accessories)
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1},
- }
+ pairing_id = "00:00:00:00:00:00"
- pairing.pairing_data.update(
- {"AccessoryPairingID": discovery_info["properties"]["id"]}
- )
+ accessories_obj = Accessories()
+ for accessory in accessories:
+ accessories_obj.add_accessory(accessory)
+ pairing = await fake_controller.add_paired_device(accessories_obj, pairing_id)
config_entry = MockConfigEntry(
version=1,
domain="homekit_controller",
entry_id="TestData",
- data=pairing.pairing_data,
+ data={"AccessoryPairingID": pairing_id},
title="test",
connection_class=config_entries.CONN_CLASS_LOCAL_PUSH,
)
-
config_entry.add_to_hass(hass)
- pairing_cls_loc = "homeassistant.components.homekit_controller.connection.IpPairing"
- with mock.patch(pairing_cls_loc) as pairing_cls:
- pairing_cls.return_value = pairing
- await config_entry.async_setup(hass)
- await hass.async_block_till_done()
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
return config_entry, pairing
@@ -265,7 +114,11 @@ async def device_config_changed(hass, accessories):
# Update the accessories our FakePairing knows about
controller = hass.data[CONTROLLER]
pairing = controller.pairings["00:00:00:00:00:00"]
- pairing.accessories = accessories
+
+ accessories_obj = Accessories()
+ for accessory in accessories:
+ accessories_obj.add_accessory(accessory)
+ pairing.accessories = accessories_obj
discovery_info = {
"name": "TestDevice",
@@ -293,15 +146,20 @@ async def device_config_changed(hass, accessories):
await hass.async_block_till_done()
-async def setup_test_component(hass, services, capitalize=False, suffix=None):
+async def setup_test_component(hass, setup_accessory, capitalize=False, suffix=None):
"""Load a fake homekit accessory based on a homekit accessory model.
If capitalize is True, property names will be in upper case.
If suffix is set, entityId will include the suffix
"""
+ accessory = Accessory.create_with_info(
+ "TestDevice", "example.com", "Test", "0001", "0.1"
+ )
+ setup_accessory(accessory)
+
domain = None
- for service in services:
+ for service in accessory.services:
service_name = ServicesTypes.get_short(service.type)
if service_name in HOMEKIT_ACCESSORY_DISPATCH:
domain = HOMEKIT_ACCESSORY_DISPATCH[service_name]
@@ -309,9 +167,6 @@ async def setup_test_component(hass, services, capitalize=False, suffix=None):
assert domain, "Cannot map test homekit services to Home Assistant domain"
- accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1")
- accessory.services.extend(services)
-
config_entry, pairing = await setup_test_accessories(hass, [accessory])
- entity = "testdevice" if suffix is None else "testdevice_{}".format(suffix)
+ entity = "testdevice" if suffix is None else f"testdevice_{suffix}"
return Helper(hass, ".".join((domain, entity)), pairing, accessory, config_entry)
diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py
index cca272be062..99e86335cdb 100644
--- a/tests/components/homekit_controller/conftest.py
+++ b/tests/components/homekit_controller/conftest.py
@@ -2,6 +2,8 @@
import datetime
from unittest import mock
+from aiohomekit.testing import FakeController
+import asynctest
import pytest
@@ -12,3 +14,11 @@ def utcnow(request):
with mock.patch("homeassistant.util.dt.utcnow") as dt_utcnow:
dt_utcnow.return_value = start_dt
yield dt_utcnow
+
+
+@pytest.fixture
+def controller(hass):
+ """Replace aiohomekit.Controller with an instance of aiohomekit.testing.FakeController."""
+ instance = FakeController()
+ with asynctest.patch("aiohomekit.Controller", return_value=instance):
+ yield instance
diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py
index bb7695840f0..ee048f93ca7 100644
--- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py
+++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py
@@ -6,7 +6,8 @@ https://github.com/home-assistant/home-assistant/issues/15336
from unittest import mock
-from homekit import AccessoryDisconnectedError
+from aiohomekit import AccessoryDisconnectedError
+from aiohomekit.testing import FakePairing
from homeassistant.components.climate.const import (
SUPPORT_TARGET_HUMIDITY,
@@ -15,7 +16,6 @@ from homeassistant.components.climate.const import (
from homeassistant.config_entries import ENTRY_STATE_SETUP_RETRY
from tests.components.homekit_controller.common import (
- FakePairing,
Helper,
device_config_changed,
setup_accessories_from_file,
@@ -149,14 +149,8 @@ async def test_ecobee3_setup_connection_failure(hass):
# a successful setup.
# We just advance time by 5 minutes so that the retry happens, rather
- # than manually invoking async_setup_entry - this means we need to
- # make sure the IpPairing mock is in place or we'll try to connect to
- # a real device. Normally this mocking is done by the helper in
- # setup_test_accessories.
- pairing_cls_loc = "homeassistant.components.homekit_controller.connection.IpPairing"
- with mock.patch(pairing_cls_loc) as pairing_cls:
- pairing_cls.return_value = pairing
- await time_changed(hass, 5 * 60)
+ # than manually invoking async_setup_entry.
+ await time_changed(hass, 5 * 60)
climate = entity_registry.async_get("climate.homew")
assert climate.unique_id == "homekit-123456789012-16"
diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py
new file mode 100644
index 00000000000..b1a8c0a636f
--- /dev/null
+++ b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py
@@ -0,0 +1,37 @@
+"""
+Regression tests for Ecobee occupancy.
+
+https://github.com/home-assistant/home-assistant/issues/31827
+"""
+
+from tests.components.homekit_controller.common import (
+ Helper,
+ setup_accessories_from_file,
+ setup_test_accessories,
+)
+
+
+async def test_ecobee_occupancy_setup(hass):
+ """Test that an Ecbobee occupancy sensor be correctly setup in HA."""
+ accessories = await setup_accessories_from_file(hass, "ecobee_occupancy.json")
+ config_entry, pairing = await setup_test_accessories(hass, accessories)
+
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ sensor = entity_registry.async_get("binary_sensor.master_fan")
+ assert sensor.unique_id == "homekit-111111111111-56"
+
+ sensor_helper = Helper(
+ hass, "binary_sensor.master_fan", pairing, accessories[0], config_entry
+ )
+ sensor_state = await sensor_helper.poll_and_get_state()
+ assert sensor_state.attributes["friendly_name"] == "Master Fan"
+
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+
+ device = device_registry.async_get(sensor.device_id)
+ assert device.manufacturer == "ecobee Inc."
+ assert device.name == "Master Fan"
+ assert device.model == "ecobee Switch+"
+ assert device.sw_version == "4.5.130201"
+ assert device.via_device_id is None
diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py
index 52339bb6635..2abd12b3df4 100644
--- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py
+++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py
@@ -3,7 +3,8 @@
from datetime import timedelta
from unittest import mock
-from homekit.exceptions import AccessoryDisconnectedError, EncryptionError
+from aiohomekit.exceptions import AccessoryDisconnectedError, EncryptionError
+from aiohomekit.testing import FakePairing
import pytest
from homeassistant.components.light import SUPPORT_BRIGHTNESS, SUPPORT_COLOR
@@ -11,7 +12,6 @@ import homeassistant.util.dt as dt_util
from tests.common import async_fire_time_changed
from tests.components.homekit_controller.common import (
- FakePairing,
Helper,
setup_accessories_from_file,
setup_test_accessories,
diff --git a/tests/components/homekit_controller/specific_devices/test_lg_tv.py b/tests/components/homekit_controller/specific_devices/test_lg_tv.py
new file mode 100644
index 00000000000..acebac95006
--- /dev/null
+++ b/tests/components/homekit_controller/specific_devices/test_lg_tv.py
@@ -0,0 +1,64 @@
+"""Make sure that handling real world LG HomeKit characteristics isn't broken."""
+
+
+from homeassistant.components.media_player.const import (
+ SUPPORT_PAUSE,
+ SUPPORT_PLAY,
+ SUPPORT_SELECT_SOURCE,
+)
+
+from tests.components.homekit_controller.common import (
+ Helper,
+ setup_accessories_from_file,
+ setup_test_accessories,
+)
+
+
+async def test_lg_tv(hass):
+ """Test that a Koogeek LS1 can be correctly setup in HA."""
+ accessories = await setup_accessories_from_file(hass, "lg_tv.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("media_player.lg_webos_tv_af80")
+ assert entry.unique_id == "homekit-999AAAAAA999-48"
+
+ helper = Helper(
+ hass, "media_player.lg_webos_tv_af80", 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"] == "LG webOS TV AF80"
+
+ # Assert that all channels were found and that we know which is active.
+ assert state.attributes["source_list"] == [
+ "AirPlay",
+ "Live TV",
+ "HDMI 1",
+ "Sony",
+ "Apple",
+ "AV",
+ "HDMI 4",
+ ]
+ assert state.attributes["source"] == "HDMI 4"
+
+ # Assert that all optional features the LS1 supports are detected
+ assert state.attributes["supported_features"] == (
+ SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE
+ )
+
+ # The LG TV doesn't (at least at this patch level) report its media state via
+ # CURRENT_MEDIA_STATE. Therefore "ok" is the best we can say.
+ assert state.state == "ok"
+
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+
+ device = device_registry.async_get(entry.device_id)
+ assert device.manufacturer == "LG Electronics"
+ assert device.name == "LG webOS TV AF80"
+ assert device.model == "OLED55B9PUA"
+ assert device.sw_version == "04.71.04"
+ assert device.via_device_id is None
diff --git a/tests/components/homekit_controller/test_air_quality.py b/tests/components/homekit_controller/test_air_quality.py
index 41f39d7d7a3..52c79f2b28a 100644
--- a/tests/components/homekit_controller/test_air_quality.py
+++ b/tests/components/homekit_controller/test_air_quality.py
@@ -1,39 +1,39 @@
"""Basic checks for HomeKit air quality sensor."""
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
+from tests.components.homekit_controller.common import setup_test_component
-def create_air_quality_sensor_service():
+def create_air_quality_sensor_service(accessory):
"""Define temperature characteristics."""
- service = FakeService("public.hap.service.sensor.air-quality")
+ service = accessory.add_service(ServicesTypes.AIR_QUALITY_SENSOR)
- cur_state = service.add_characteristic("air-quality")
+ cur_state = service.add_char(CharacteristicsTypes.AIR_QUALITY)
cur_state.value = 5
- cur_state = service.add_characteristic("density.ozone")
+ cur_state = service.add_char(CharacteristicsTypes.DENSITY_OZONE)
cur_state.value = 1111
- cur_state = service.add_characteristic("density.no2")
+ cur_state = service.add_char(CharacteristicsTypes.DENSITY_NO2)
cur_state.value = 2222
- cur_state = service.add_characteristic("density.so2")
+ cur_state = service.add_char(CharacteristicsTypes.DENSITY_SO2)
cur_state.value = 3333
- cur_state = service.add_characteristic("density.pm25")
+ cur_state = service.add_char(CharacteristicsTypes.DENSITY_PM25)
cur_state.value = 4444
- cur_state = service.add_characteristic("density.pm10")
+ cur_state = service.add_char(CharacteristicsTypes.DENSITY_PM10)
cur_state.value = 5555
- cur_state = service.add_characteristic("density.voc")
+ cur_state = service.add_char(CharacteristicsTypes.DENSITY_VOC)
cur_state.value = 6666
- return service
-
async def test_air_quality_sensor_read_state(hass, utcnow):
"""Test reading the state of a HomeKit temperature sensor accessory."""
- sensor = create_air_quality_sensor_service()
- helper = await setup_test_component(hass, [sensor])
+ helper = await setup_test_component(hass, create_air_quality_sensor_service)
state = await helper.poll_and_get_state()
assert state.state == "4444"
diff --git a/tests/components/homekit_controller/test_alarm_control_panel.py b/tests/components/homekit_controller/test_alarm_control_panel.py
index 3b0662dc16d..5694be5f955 100644
--- a/tests/components/homekit_controller/test_alarm_control_panel.py
+++ b/tests/components/homekit_controller/test_alarm_control_panel.py
@@ -1,34 +1,34 @@
"""Basic checks for HomeKitalarm_control_panel."""
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
+from tests.components.homekit_controller.common import setup_test_component
CURRENT_STATE = ("security-system", "security-system-state.current")
TARGET_STATE = ("security-system", "security-system-state.target")
-def create_security_system_service():
+def create_security_system_service(accessory):
"""Define a security-system characteristics as per page 219 of HAP spec."""
- service = FakeService("public.hap.service.security-system")
+ service = accessory.add_service(ServicesTypes.SECURITY_SYSTEM)
- cur_state = service.add_characteristic("security-system-state.current")
+ cur_state = service.add_char(CharacteristicsTypes.SECURITY_SYSTEM_STATE_CURRENT)
cur_state.value = 0
- targ_state = service.add_characteristic("security-system-state.target")
+ targ_state = service.add_char(CharacteristicsTypes.SECURITY_SYSTEM_STATE_TARGET)
targ_state.value = 0
# According to the spec, a battery-level characteristic is normally
# part of a separate service. However as the code was written (which
# predates this test) the battery level would have to be part of the lock
# service as it is here.
- targ_state = service.add_characteristic("battery-level")
+ targ_state = service.add_char(CharacteristicsTypes.BATTERY_LEVEL)
targ_state.value = 50
- return service
-
async def test_switch_change_alarm_state(hass, utcnow):
"""Test that we can turn a HomeKit alarm on and off again."""
- alarm_control_panel = create_security_system_service()
- helper = await setup_test_component(hass, [alarm_control_panel])
+ helper = await setup_test_component(hass, create_security_system_service)
await hass.services.async_call(
"alarm_control_panel",
@@ -65,8 +65,7 @@ async def test_switch_change_alarm_state(hass, utcnow):
async def test_switch_read_alarm_state(hass, utcnow):
"""Test that we can read the state of a HomeKit alarm accessory."""
- alarm_control_panel = create_security_system_service()
- helper = await setup_test_component(hass, [alarm_control_panel])
+ helper = await setup_test_component(hass, create_security_system_service)
helper.characteristics[CURRENT_STATE].value = 0
state = await helper.poll_and_get_state()
diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py
index f472ac38d1d..8817ed5c22d 100644
--- a/tests/components/homekit_controller/test_binary_sensor.py
+++ b/tests/components/homekit_controller/test_binary_sensor.py
@@ -1,25 +1,33 @@
"""Basic checks for HomeKit motion sensors and contact sensors."""
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASS_MOTION,
+ DEVICE_CLASS_OCCUPANCY,
+ DEVICE_CLASS_OPENING,
+ DEVICE_CLASS_SMOKE,
+)
+
+from tests.components.homekit_controller.common import setup_test_component
MOTION_DETECTED = ("motion", "motion-detected")
CONTACT_STATE = ("contact", "contact-state")
SMOKE_DETECTED = ("smoke", "smoke-detected")
+OCCUPANCY_DETECTED = ("occupancy", "occupancy-detected")
-def create_motion_sensor_service():
+def create_motion_sensor_service(accessory):
"""Define motion characteristics as per page 225 of HAP spec."""
- service = FakeService("public.hap.service.sensor.motion")
+ service = accessory.add_service(ServicesTypes.MOTION_SENSOR)
- cur_state = service.add_characteristic("motion-detected")
+ cur_state = service.add_char(CharacteristicsTypes.MOTION_DETECTED)
cur_state.value = 0
- return service
-
async def test_motion_sensor_read_state(hass, utcnow):
"""Test that we can read the state of a HomeKit motion sensor accessory."""
- sensor = create_motion_sensor_service()
- helper = await setup_test_component(hass, [sensor])
+ helper = await setup_test_component(hass, create_motion_sensor_service)
helper.characteristics[MOTION_DETECTED].value = False
state = await helper.poll_and_get_state()
@@ -29,21 +37,20 @@ async def test_motion_sensor_read_state(hass, utcnow):
state = await helper.poll_and_get_state()
assert state.state == "on"
+ assert state.attributes["device_class"] == DEVICE_CLASS_MOTION
-def create_contact_sensor_service():
+
+def create_contact_sensor_service(accessory):
"""Define contact characteristics."""
- service = FakeService("public.hap.service.sensor.contact")
+ service = accessory.add_service(ServicesTypes.CONTACT_SENSOR)
- cur_state = service.add_characteristic("contact-state")
+ cur_state = service.add_char(CharacteristicsTypes.CONTACT_STATE)
cur_state.value = 0
- return service
-
async def test_contact_sensor_read_state(hass, utcnow):
"""Test that we can read the state of a HomeKit contact accessory."""
- sensor = create_contact_sensor_service()
- helper = await setup_test_component(hass, [sensor])
+ helper = await setup_test_component(hass, create_contact_sensor_service)
helper.characteristics[CONTACT_STATE].value = 0
state = await helper.poll_and_get_state()
@@ -53,21 +60,20 @@ async def test_contact_sensor_read_state(hass, utcnow):
state = await helper.poll_and_get_state()
assert state.state == "on"
+ assert state.attributes["device_class"] == DEVICE_CLASS_OPENING
-def create_smoke_sensor_service():
+
+def create_smoke_sensor_service(accessory):
"""Define smoke sensor characteristics."""
- service = FakeService("public.hap.service.sensor.smoke")
+ service = accessory.add_service(ServicesTypes.SMOKE_SENSOR)
- cur_state = service.add_characteristic("smoke-detected")
+ cur_state = service.add_char(CharacteristicsTypes.SMOKE_DETECTED)
cur_state.value = 0
- return service
-
async def test_smoke_sensor_read_state(hass, utcnow):
"""Test that we can read the state of a HomeKit contact accessory."""
- sensor = create_smoke_sensor_service()
- helper = await setup_test_component(hass, [sensor])
+ helper = await setup_test_component(hass, create_smoke_sensor_service)
helper.characteristics[SMOKE_DETECTED].value = 0
state = await helper.poll_and_get_state()
@@ -77,4 +83,27 @@ async def test_smoke_sensor_read_state(hass, utcnow):
state = await helper.poll_and_get_state()
assert state.state == "on"
- assert state.attributes["device_class"] == "smoke"
+ assert state.attributes["device_class"] == DEVICE_CLASS_SMOKE
+
+
+def create_occupancy_sensor_service(accessory):
+ """Define occupancy characteristics."""
+ service = accessory.add_service(ServicesTypes.OCCUPANCY_SENSOR)
+
+ cur_state = service.add_char(CharacteristicsTypes.OCCUPANCY_DETECTED)
+ cur_state.value = 0
+
+
+async def test_occupancy_sensor_read_state(hass, utcnow):
+ """Test that we can read the state of a HomeKit occupancy sensor accessory."""
+ helper = await setup_test_component(hass, create_occupancy_sensor_service)
+
+ helper.characteristics[OCCUPANCY_DETECTED].value = False
+ state = await helper.poll_and_get_state()
+ assert state.state == "off"
+
+ helper.characteristics[OCCUPANCY_DETECTED].value = True
+ state = await helper.poll_and_get_state()
+ assert state.state == "on"
+
+ assert state.attributes["device_class"] == DEVICE_CLASS_OCCUPANCY
diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py
index e076b2975e2..9bcadb6604e 100644
--- a/tests/components/homekit_controller/test_climate.py
+++ b/tests/components/homekit_controller/test_climate.py
@@ -1,4 +1,7 @@
"""Basic checks for HomeKitclimate."""
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
from homeassistant.components.climate.const import (
DOMAIN,
HVAC_MODE_COOL,
@@ -10,7 +13,7 @@ from homeassistant.components.climate.const import (
SERVICE_SET_TEMPERATURE,
)
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from tests.components.homekit_controller.common import setup_test_component
HEATING_COOLING_TARGET = ("thermostat", "heating-cooling.target")
HEATING_COOLING_CURRENT = ("thermostat", "heating-cooling.current")
@@ -20,63 +23,65 @@ HUMIDITY_TARGET = ("thermostat", "relative-humidity.target")
HUMIDITY_CURRENT = ("thermostat", "relative-humidity.current")
-def create_thermostat_service():
+def create_thermostat_service(accessory):
"""Define thermostat characteristics."""
- service = FakeService("public.hap.service.thermostat")
+ service = accessory.add_service(ServicesTypes.THERMOSTAT)
- char = service.add_characteristic("heating-cooling.target")
+ char = service.add_char(CharacteristicsTypes.HEATING_COOLING_TARGET)
char.value = 0
- char = service.add_characteristic("heating-cooling.current")
+ char = service.add_char(CharacteristicsTypes.HEATING_COOLING_CURRENT)
char.value = 0
- char = service.add_characteristic("temperature.target")
+ char = service.add_char(CharacteristicsTypes.TEMPERATURE_TARGET)
+ char.minValue = 7
+ char.maxValue = 35
char.value = 0
- char = service.add_characteristic("temperature.current")
+ char = service.add_char(CharacteristicsTypes.TEMPERATURE_CURRENT)
char.value = 0
- char = service.add_characteristic("relative-humidity.target")
+ char = service.add_char(CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET)
char.value = 0
- char = service.add_characteristic("relative-humidity.current")
+ char = service.add_char(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT)
char.value = 0
- return service
-
-async def test_climate_respect_supported_op_modes_1(hass, utcnow):
- """Test that climate respects minValue/maxValue hints."""
- service = FakeService("public.hap.service.thermostat")
- char = service.add_characteristic("heating-cooling.target")
+def create_thermostat_service_min_max(accessory):
+ """Define thermostat characteristics."""
+ service = accessory.add_service(ServicesTypes.THERMOSTAT)
+ char = service.add_char(CharacteristicsTypes.HEATING_COOLING_TARGET)
char.value = 0
char.minValue = 0
char.maxValue = 1
- helper = await setup_test_component(hass, [service])
+async def test_climate_respect_supported_op_modes_1(hass, utcnow):
+ """Test that climate respects minValue/maxValue hints."""
+ helper = await setup_test_component(hass, create_thermostat_service_min_max)
state = await helper.poll_and_get_state()
assert state.attributes["hvac_modes"] == ["off", "heat"]
-async def test_climate_respect_supported_op_modes_2(hass, utcnow):
- """Test that climate respects validValue hints."""
- service = FakeService("public.hap.service.thermostat")
- char = service.add_characteristic("heating-cooling.target")
+def create_thermostat_service_valid_vals(accessory):
+ """Define thermostat characteristics."""
+ service = accessory.add_service(ServicesTypes.THERMOSTAT)
+ char = service.add_char(CharacteristicsTypes.HEATING_COOLING_TARGET)
char.value = 0
char.valid_values = [0, 1, 2]
- helper = await setup_test_component(hass, [service])
+async def test_climate_respect_supported_op_modes_2(hass, utcnow):
+ """Test that climate respects validValue hints."""
+ helper = await setup_test_component(hass, create_thermostat_service_valid_vals)
state = await helper.poll_and_get_state()
assert state.attributes["hvac_modes"] == ["off", "heat", "cool"]
async def test_climate_change_thermostat_state(hass, utcnow):
"""Test that we can turn a HomeKit thermostat on and off again."""
- from homekit.model.services import ThermostatService
-
- helper = await setup_test_component(hass, [ThermostatService()])
+ helper = await setup_test_component(hass, create_thermostat_service)
await hass.services.async_call(
DOMAIN,
@@ -114,9 +119,7 @@ async def test_climate_change_thermostat_state(hass, utcnow):
async def test_climate_change_thermostat_temperature(hass, utcnow):
"""Test that we can turn a HomeKit thermostat on and off again."""
- from homekit.model.services import ThermostatService
-
- helper = await setup_test_component(hass, [ThermostatService()])
+ helper = await setup_test_component(hass, create_thermostat_service)
await hass.services.async_call(
DOMAIN,
@@ -137,7 +140,7 @@ async def test_climate_change_thermostat_temperature(hass, utcnow):
async def test_climate_change_thermostat_humidity(hass, utcnow):
"""Test that we can turn a HomeKit thermostat on and off again."""
- helper = await setup_test_component(hass, [create_thermostat_service()])
+ helper = await setup_test_component(hass, create_thermostat_service)
await hass.services.async_call(
DOMAIN,
@@ -158,7 +161,7 @@ async def test_climate_change_thermostat_humidity(hass, utcnow):
async def test_climate_read_thermostat_state(hass, utcnow):
"""Test that we can read the state of a HomeKit thermostat accessory."""
- helper = await setup_test_component(hass, [create_thermostat_service()])
+ helper = await setup_test_component(hass, create_thermostat_service)
# Simulate that heating is on
helper.characteristics[TEMPERATURE_CURRENT].value = 19
@@ -200,7 +203,7 @@ async def test_climate_read_thermostat_state(hass, utcnow):
async def test_hvac_mode_vs_hvac_action(hass, utcnow):
"""Check that we haven't conflated hvac_mode and hvac_action."""
- helper = await setup_test_component(hass, [create_thermostat_service()])
+ helper = await setup_test_component(hass, create_thermostat_service)
# Simulate that current temperature is above target temp
# Heating might be on, but hvac_action currently 'off'
diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py
index 2a7f36ba470..760c5f30436 100644
--- a/tests/components/homekit_controller/test_config_flow.py
+++ b/tests/components/homekit_controller/test_config_flow.py
@@ -2,40 +2,40 @@
import json
from unittest import mock
-import homekit
+import aiohomekit
+from aiohomekit.model import Accessories, Accessory
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+import asynctest
+from asynctest import patch
import pytest
from homeassistant.components.homekit_controller import config_flow
-from homeassistant.components.homekit_controller.const import KNOWN_DEVICES
from tests.common import MockConfigEntry
-from tests.components.homekit_controller.common import (
- Accessory,
- FakeService,
- setup_platform,
-)
+from tests.components.homekit_controller.common import setup_platform
PAIRING_START_FORM_ERRORS = [
- (homekit.BusyError, "busy_error"),
- (homekit.MaxTriesError, "max_tries_error"),
+ (aiohomekit.BusyError, "busy_error"),
+ (aiohomekit.MaxTriesError, "max_tries_error"),
(KeyError, "pairing_failed"),
]
PAIRING_START_ABORT_ERRORS = [
- (homekit.AccessoryNotFoundError, "accessory_not_found_error"),
- (homekit.UnavailableError, "already_paired"),
+ (aiohomekit.AccessoryNotFoundError, "accessory_not_found_error"),
+ (aiohomekit.UnavailableError, "already_paired"),
]
PAIRING_FINISH_FORM_ERRORS = [
- (homekit.exceptions.MalformedPinError, "authentication_error"),
- (homekit.MaxPeersError, "max_peers_error"),
- (homekit.AuthenticationError, "authentication_error"),
- (homekit.UnknownError, "unknown_error"),
+ (aiohomekit.exceptions.MalformedPinError, "authentication_error"),
+ (aiohomekit.MaxPeersError, "max_peers_error"),
+ (aiohomekit.AuthenticationError, "authentication_error"),
+ (aiohomekit.UnknownError, "unknown_error"),
(KeyError, "pairing_failed"),
]
PAIRING_FINISH_ABORT_ERRORS = [
- (homekit.AccessoryNotFoundError, "accessory_not_found_error")
+ (aiohomekit.AccessoryNotFoundError, "accessory_not_found_error")
]
INVALID_PAIRING_CODES = [
@@ -60,28 +60,30 @@ VALID_PAIRING_CODES = [
]
-def _setup_flow_handler(hass):
+def _setup_flow_handler(hass, pairing=None):
flow = config_flow.HomekitControllerFlowHandler()
flow.hass = hass
flow.context = {}
+ finish_pairing = asynctest.CoroutineMock(return_value=pairing)
+
+ discovery = mock.Mock()
+ discovery.device_id = "00:00:00:00:00:00"
+ discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing)
+
flow.controller = mock.Mock()
flow.controller.pairings = {}
+ flow.controller.find_ip_by_device_id = asynctest.CoroutineMock(
+ return_value=discovery
+ )
return flow
-async def _setup_flow_zeroconf(hass, discovery_info):
- result = await hass.config_entries.flow.async_init(
- "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
- )
- return result
-
-
@pytest.mark.parametrize("pairing_code", INVALID_PAIRING_CODES)
def test_invalid_pairing_codes(pairing_code):
"""Test ensure_pin_format raises for an invalid pin code."""
- with pytest.raises(homekit.exceptions.MalformedPinError):
+ with pytest.raises(aiohomekit.exceptions.MalformedPinError):
config_flow.ensure_pin_format(pairing_code)
@@ -95,243 +97,174 @@ def test_valid_pairing_codes(pairing_code):
assert len(valid_pin[2]) == 3
-async def test_discovery_works(hass):
- """Test a device being discovered."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
+def get_flow_context(hass, result):
+ """Get the flow context from the result of async_init or async_configure."""
+ flow = next(
+ (
+ flow
+ for flow in hass.config_entries.flow.async_progress()
+ if flow["flow_id"] == result["flow_id"]
+ )
+ )
+
+ return flow["context"]
+
+
+def get_device_discovery_info(device, upper_case_props=False, missing_csharp=False):
+ """Turn a aiohomekit format zeroconf entry into a homeassistant one."""
+ record = device.info
+ result = {
+ "host": record["address"],
+ "port": record["port"],
+ "hostname": record["name"],
+ "type": "_hap._tcp.local.",
+ "name": record["name"],
+ "properties": {
+ "md": record["md"],
+ "pv": record["pv"],
+ "id": device.device_id,
+ "c#": record["c#"],
+ "s#": record["s#"],
+ "ff": record["ff"],
+ "ci": record["ci"],
+ "sf": 0x01, # record["sf"],
+ "sh": "",
+ },
}
- flow = _setup_flow_handler(hass)
+ if missing_csharp:
+ del result["properties"]["c#"]
+
+ if upper_case_props:
+ result["properties"] = {
+ key.upper(): val for (key, val) in result["properties"].items()
+ }
+
+ return result
+
+
+def setup_mock_accessory(controller):
+ """Add a bridge accessory to a test controller."""
+ bridge = Accessories()
+
+ accessory = Accessory.create_with_info(
+ name="Koogeek-LS1-20833F",
+ manufacturer="Koogeek",
+ model="LS1",
+ serial_number="12345",
+ firmware_revision="1.1",
+ )
+
+ service = accessory.add_service(ServicesTypes.LIGHTBULB)
+ on_char = service.add_char(CharacteristicsTypes.ON)
+ on_char.value = 0
+
+ bridge.add_accessory(accessory)
+
+ return controller.add_device(bridge)
+
+
+@pytest.mark.parametrize("upper_case_props", [True, False])
+@pytest.mark.parametrize("missing_csharp", [True, False])
+async def test_discovery_works(hass, controller, upper_case_props, missing_csharp):
+ """Test a device being discovered."""
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device, upper_case_props, missing_csharp)
# Device is discovered
- result = await flow.async_step_zeroconf(discovery_info)
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
assert result["type"] == "form"
assert result["step_id"] == "pair"
- assert flow.context == {
+ assert get_flow_context(hass, result) == {
"hkid": "00:00:00:00:00:00",
+ "source": "zeroconf",
"title_placeholders": {"name": "TestDevice"},
"unique_id": "00:00:00:00:00:00",
}
# User initiates pairing - device enters pairing mode and displays code
- result = await flow.async_step_pair({})
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == "form"
assert result["step_id"] == "pair"
- assert flow.controller.start_pairing.call_count == 1
-
- pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"})
-
- pairing.list_accessories_and_characteristics.return_value = [
- {
- "aid": 1,
- "services": [
- {
- "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}],
- "type": "3e",
- }
- ],
- }
- ]
# Pairing doesn't error error and pairing results
- flow.controller.pairings = {"00:00:00:00:00:00": pairing}
- result = await flow.async_step_pair({"pairing_code": "111-22-333"})
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"pairing_code": "111-22-333"}
+ )
assert result["type"] == "create_entry"
assert result["title"] == "Koogeek-LS1-20833F"
- assert result["data"] == pairing.pairing_data
+ assert result["data"] == {}
-async def test_discovery_works_upper_case(hass):
- """Test a device being discovered."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"MD": "TestDevice", "ID": "00:00:00:00:00:00", "C#": 1, "SF": 1},
- }
-
- flow = _setup_flow_handler(hass)
-
- # Device is discovered
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
- }
-
- # User initiates pairing - device enters pairing mode and displays code
- result = await flow.async_step_pair({})
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.controller.start_pairing.call_count == 1
-
- pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"})
-
- pairing.list_accessories_and_characteristics.return_value = [
- {
- "aid": 1,
- "services": [
- {
- "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}],
- "type": "3e",
- }
- ],
- }
- ]
-
- flow.controller.pairings = {"00:00:00:00:00:00": pairing}
- result = await flow.async_step_pair({"pairing_code": "111-22-333"})
- assert result["type"] == "create_entry"
- assert result["title"] == "Koogeek-LS1-20833F"
- assert result["data"] == pairing.pairing_data
-
-
-async def test_discovery_works_missing_csharp(hass):
- """Test a device being discovered that has missing mdns attrs."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "sf": 1},
- }
-
- flow = _setup_flow_handler(hass)
-
- # Device is discovered
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
- }
-
- # User initiates pairing - device enters pairing mode and displays code
- result = await flow.async_step_pair({})
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.controller.start_pairing.call_count == 1
-
- pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"})
-
- pairing.list_accessories_and_characteristics.return_value = [
- {
- "aid": 1,
- "services": [
- {
- "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}],
- "type": "3e",
- }
- ],
- }
- ]
-
- flow.controller.pairings = {"00:00:00:00:00:00": pairing}
-
- result = await flow.async_step_pair({"pairing_code": "111-22-333"})
- assert result["type"] == "create_entry"
- assert result["title"] == "Koogeek-LS1-20833F"
- assert result["data"] == pairing.pairing_data
-
-
-async def test_abort_duplicate_flow(hass):
+async def test_abort_duplicate_flow(hass, controller):
"""Already paired."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
- result = await _setup_flow_zeroconf(hass, discovery_info)
+ # Device is discovered
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
assert result["type"] == "form"
assert result["step_id"] == "pair"
- result = await _setup_flow_zeroconf(hass, discovery_info)
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
assert result["type"] == "abort"
assert result["reason"] == "already_in_progress"
-async def test_pair_already_paired_1(hass):
+async def test_pair_already_paired_1(hass, controller):
"""Already paired."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 0},
- }
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
- flow = _setup_flow_handler(hass)
+ # Flag device as already paired
+ discovery_info["properties"]["sf"] = 0x0
- result = await flow.async_step_zeroconf(discovery_info)
+ # Device is discovered
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
assert result["type"] == "abort"
assert result["reason"] == "already_paired"
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
- }
-async def test_discovery_ignored_model(hass):
+async def test_discovery_ignored_model(hass, controller):
"""Already paired."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {
- "md": config_flow.HOMEKIT_IGNORE[0],
- "id": "00:00:00:00:00:00",
- "c#": 1,
- "sf": 1,
- },
- }
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
+ discovery_info["properties"]["md"] = config_flow.HOMEKIT_IGNORE[0]
- flow = _setup_flow_handler(hass)
-
- result = await flow.async_step_zeroconf(discovery_info)
+ # Device is discovered
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
assert result["type"] == "abort"
assert result["reason"] == "ignored_model"
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
- }
-async def test_discovery_invalid_config_entry(hass):
- """There is already a config entry for the pairing id but its invalid."""
+async def test_discovery_invalid_config_entry(hass, controller):
+ """There is already a config entry for the pairing id but it's invalid."""
MockConfigEntry(
- domain="homekit_controller", data={"AccessoryPairingID": "00:00:00:00:00:00"}
+ domain="homekit_controller",
+ data={"AccessoryPairingID": "00:00:00:00:00:00"},
+ unique_id="00:00:00:00:00:00",
).add_to_hass(hass)
# We just added a mock config entry so it must be visible in hass
assert len(hass.config_entries.async_entries()) == 1
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
- flow = _setup_flow_handler(hass)
-
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
- }
+ # Device is discovered
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
# Discovery of a HKID that is in a pairable state but for which there is
# already a config entry - in that case the stale config entry is
@@ -339,378 +272,227 @@ async def test_discovery_invalid_config_entry(hass):
config_entry_count = len(hass.config_entries.async_entries())
assert config_entry_count == 0
+ # And new config flow should continue allowing user to set up a new pairing
+ assert result["type"] == "form"
-async def test_discovery_already_configured(hass):
+
+async def test_discovery_already_configured(hass, controller):
"""Already configured."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 0},
- }
+ MockConfigEntry(
+ domain="homekit_controller",
+ data={"AccessoryPairingID": "00:00:00:00:00:00"},
+ unique_id="00:00:00:00:00:00",
+ ).add_to_hass(hass)
- await setup_platform(hass)
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
- conn = mock.Mock()
- conn.config_num = 1
- hass.data[KNOWN_DEVICES]["00:00:00:00:00:00"] = conn
-
- flow = _setup_flow_handler(hass)
-
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "abort"
- assert result["reason"] == "already_configured"
- assert flow.context == {}
-
- assert conn.async_config_num_changed.call_count == 0
-
-
-async def test_discovery_already_configured_config_change(hass):
- """Already configured."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 2, "sf": 0},
- }
-
- await setup_platform(hass)
-
- conn = mock.Mock()
- conn.config_num = 1
- hass.data[KNOWN_DEVICES]["00:00:00:00:00:00"] = conn
-
- flow = _setup_flow_handler(hass)
-
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "abort"
- assert result["reason"] == "already_configured"
- assert flow.context == {}
-
- assert conn.async_refresh_entity_map.call_args == mock.call(2)
-
-
-async def test_pair_unable_to_pair(hass):
- """Pairing completed without exception, but didn't create a pairing."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
-
- flow = _setup_flow_handler(hass)
+ # Set device as already paired
+ discovery_info["properties"]["sf"] = 0x00
# Device is discovered
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
- }
-
- # User initiates pairing - device enters pairing mode and displays code
- result = await flow.async_step_pair({})
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.controller.start_pairing.call_count == 1
-
- # Pairing doesn't error but no pairing object is generated
- result = await flow.async_step_pair({"pairing_code": "111-22-333"})
- assert result["type"] == "form"
- assert result["errors"]["pairing_code"] == "unable_to_pair"
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
@pytest.mark.parametrize("exception,expected", PAIRING_START_ABORT_ERRORS)
-async def test_pair_abort_errors_on_start(hass, exception, expected):
+async def test_pair_abort_errors_on_start(hass, controller, exception, expected):
"""Test various pairing errors."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
- flow = _setup_flow_handler(hass)
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
# Device is discovered
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
- }
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
# User initiates pairing - device refuses to enter pairing mode
- with mock.patch.object(flow.controller, "start_pairing") as start_pairing:
- start_pairing.side_effect = exception("error")
- result = await flow.async_step_pair({})
-
+ test_exc = exception("error")
+ with patch.object(device, "start_pairing", side_effect=test_exc):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == "abort"
assert result["reason"] == expected
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
- }
@pytest.mark.parametrize("exception,expected", PAIRING_START_FORM_ERRORS)
-async def test_pair_form_errors_on_start(hass, exception, expected):
+async def test_pair_form_errors_on_start(hass, controller, exception, expected):
"""Test various pairing errors."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
- flow = _setup_flow_handler(hass)
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
# Device is discovered
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.context == {
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
+
+ assert get_flow_context(hass, result) == {
"hkid": "00:00:00:00:00:00",
"title_placeholders": {"name": "TestDevice"},
"unique_id": "00:00:00:00:00:00",
+ "source": "zeroconf",
}
# User initiates pairing - device refuses to enter pairing mode
- with mock.patch.object(flow.controller, "start_pairing") as start_pairing:
- start_pairing.side_effect = exception("error")
- result = await flow.async_step_pair({})
-
+ test_exc = exception("error")
+ with patch.object(device, "start_pairing", side_effect=test_exc):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == "form"
assert result["errors"]["pairing_code"] == expected
- assert flow.context == {
+
+ assert get_flow_context(hass, result) == {
"hkid": "00:00:00:00:00:00",
"title_placeholders": {"name": "TestDevice"},
"unique_id": "00:00:00:00:00:00",
+ "source": "zeroconf",
}
@pytest.mark.parametrize("exception,expected", PAIRING_FINISH_ABORT_ERRORS)
-async def test_pair_abort_errors_on_finish(hass, exception, expected):
+async def test_pair_abort_errors_on_finish(hass, controller, exception, expected):
"""Test various pairing errors."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
-
- flow = _setup_flow_handler(hass)
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
# Device is discovered
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.context == {
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
+
+ assert get_flow_context(hass, result) == {
"hkid": "00:00:00:00:00:00",
"title_placeholders": {"name": "TestDevice"},
"unique_id": "00:00:00:00:00:00",
+ "source": "zeroconf",
}
- # User initiates pairing - device enters pairing mode and displays code
- result = await flow.async_step_pair({})
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.controller.start_pairing.call_count == 1
+ # User initiates pairing - this triggers the device to show a pairing code
+ # and then HA to show a pairing form
+ finish_pairing = asynctest.CoroutineMock(side_effect=exception("error"))
+ with patch.object(device, "start_pairing", return_value=finish_pairing):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
- # User submits code - pairing fails but can be retried
- flow.finish_pairing.side_effect = exception("error")
- result = await flow.async_step_pair({"pairing_code": "111-22-333"})
+ assert result["type"] == "form"
+ assert get_flow_context(hass, result) == {
+ "hkid": "00:00:00:00:00:00",
+ "title_placeholders": {"name": "TestDevice"},
+ "unique_id": "00:00:00:00:00:00",
+ "source": "zeroconf",
+ }
+
+ # User enters pairing code
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"pairing_code": "111-22-333"}
+ )
assert result["type"] == "abort"
assert result["reason"] == expected
- assert flow.context == {
- "hkid": "00:00:00:00:00:00",
- "title_placeholders": {"name": "TestDevice"},
- "unique_id": "00:00:00:00:00:00",
- }
@pytest.mark.parametrize("exception,expected", PAIRING_FINISH_FORM_ERRORS)
-async def test_pair_form_errors_on_finish(hass, exception, expected):
+async def test_pair_form_errors_on_finish(hass, controller, exception, expected):
"""Test various pairing errors."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
-
- flow = _setup_flow_handler(hass)
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
# Device is discovered
- result = await flow.async_step_zeroconf(discovery_info)
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.context == {
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
+
+ assert get_flow_context(hass, result) == {
"hkid": "00:00:00:00:00:00",
"title_placeholders": {"name": "TestDevice"},
"unique_id": "00:00:00:00:00:00",
+ "source": "zeroconf",
}
- # User initiates pairing - device enters pairing mode and displays code
- result = await flow.async_step_pair({})
- assert result["type"] == "form"
- assert result["step_id"] == "pair"
- assert flow.controller.start_pairing.call_count == 1
+ # User initiates pairing - this triggers the device to show a pairing code
+ # and then HA to show a pairing form
+ finish_pairing = asynctest.CoroutineMock(side_effect=exception("error"))
+ with patch.object(device, "start_pairing", return_value=finish_pairing):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
- # User submits code - pairing fails but can be retried
- flow.finish_pairing.side_effect = exception("error")
- result = await flow.async_step_pair({"pairing_code": "111-22-333"})
+ assert result["type"] == "form"
+ assert get_flow_context(hass, result) == {
+ "hkid": "00:00:00:00:00:00",
+ "title_placeholders": {"name": "TestDevice"},
+ "unique_id": "00:00:00:00:00:00",
+ "source": "zeroconf",
+ }
+
+ # User enters pairing code
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"pairing_code": "111-22-333"}
+ )
assert result["type"] == "form"
assert result["errors"]["pairing_code"] == expected
- assert flow.context == {
+
+ assert get_flow_context(hass, result) == {
"hkid": "00:00:00:00:00:00",
"title_placeholders": {"name": "TestDevice"},
"unique_id": "00:00:00:00:00:00",
+ "source": "zeroconf",
}
-async def test_import_works(hass):
- """Test a device being discovered."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
-
- import_info = {"AccessoryPairingID": "00:00:00:00:00:00"}
-
- pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"})
-
- pairing.list_accessories_and_characteristics.return_value = [
- {
- "aid": 1,
- "services": [
- {
- "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}],
- "type": "3e",
- }
- ],
- }
- ]
-
- flow = _setup_flow_handler(hass)
-
- pairing_cls_imp = (
- "homeassistant.components.homekit_controller.config_flow.IpPairing"
- )
-
- with mock.patch(pairing_cls_imp) as pairing_cls:
- pairing_cls.return_value = pairing
- result = await flow.async_import_legacy_pairing(
- discovery_info["properties"], import_info
- )
-
- assert result["type"] == "create_entry"
- assert result["title"] == "Koogeek-LS1-20833F"
- assert result["data"] == pairing.pairing_data
-
-
-async def test_import_already_configured(hass):
- """Test importing a device from .homekit that is already a ConfigEntry."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1},
- }
-
- import_info = {"AccessoryPairingID": "00:00:00:00:00:00"}
-
- config_entry = MockConfigEntry(domain="homekit_controller", data=import_info)
- config_entry.add_to_hass(hass)
-
- flow = _setup_flow_handler(hass)
-
- result = await flow.async_import_legacy_pairing(
- discovery_info["properties"], import_info
- )
- assert result["type"] == "abort"
- assert result["reason"] == "already_configured"
-
-
-async def test_user_works(hass):
+async def test_user_works(hass, controller):
"""Test user initiated disovers devices."""
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "md": "TestDevice",
- "id": "00:00:00:00:00:00",
- "c#": 1,
- "sf": 1,
- }
+ setup_mock_accessory(controller)
- pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"})
- pairing.list_accessories_and_characteristics.return_value = [
- {
- "aid": 1,
- "services": [
- {
- "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}],
- "type": "3e",
- }
- ],
- }
- ]
+ # Device is discovered
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "user"}
+ )
- flow = _setup_flow_handler(hass)
-
- flow.controller.pairings = {"00:00:00:00:00:00": pairing}
- flow.controller.discover.return_value = [discovery_info]
-
- result = await flow.async_step_user()
assert result["type"] == "form"
assert result["step_id"] == "user"
+ assert get_flow_context(hass, result) == {
+ "source": "user",
+ }
- result = await flow.async_step_user({"device": "TestDevice"})
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"device": "TestDevice"}
+ )
assert result["type"] == "form"
assert result["step_id"] == "pair"
- result = await flow.async_step_pair({"pairing_code": "111-22-333"})
+ assert get_flow_context(hass, result) == {
+ "source": "user",
+ "unique_id": "00:00:00:00:00:00",
+ }
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"pairing_code": "111-22-333"}
+ )
assert result["type"] == "create_entry"
assert result["title"] == "Koogeek-LS1-20833F"
- assert result["data"] == pairing.pairing_data
-async def test_user_no_devices(hass):
+async def test_user_no_devices(hass, controller):
"""Test user initiated pairing where no devices discovered."""
- flow = _setup_flow_handler(hass)
-
- flow.controller.discover.return_value = []
- result = await flow.async_step_user()
-
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "user"}
+ )
assert result["type"] == "abort"
assert result["reason"] == "no_devices"
-async def test_user_no_unpaired_devices(hass):
+async def test_user_no_unpaired_devices(hass, controller):
"""Test user initiated pairing where no unpaired devices discovered."""
- flow = _setup_flow_handler(hass)
+ device = setup_mock_accessory(controller)
- discovery_info = {
- "name": "TestDevice",
- "host": "127.0.0.1",
- "port": 8080,
- "md": "TestDevice",
- "id": "00:00:00:00:00:00",
- "c#": 1,
- "sf": 0,
- }
+ # Pair the mock device so that it shows as paired in discovery
+ finish_pairing = await device.start_pairing(device.device_id)
+ await finish_pairing(device.pairing_code)
- flow.controller.discover.return_value = [discovery_info]
- result = await flow.async_step_user()
+ # Device discovery is requested
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "user"}
+ )
assert result["type"] == "abort"
assert result["reason"] == "no_devices"
@@ -718,15 +500,18 @@ async def test_user_no_unpaired_devices(hass):
async def test_parse_new_homekit_json(hass):
"""Test migrating recent .homekit/pairings.json files."""
- service = FakeService("public.hap.service.lightbulb")
- on_char = service.add_characteristic("on")
- on_char.value = 1
+ accessory = Accessory.create_with_info(
+ "TestDevice", "example.com", "Test", "0001", "0.1"
+ )
+ service = accessory.add_service(ServicesTypes.LIGHTBULB)
+ on_char = service.add_char(CharacteristicsTypes.ON)
+ on_char.value = 0
- accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1")
- accessory.services.append(service)
+ accessories = Accessories()
+ accessories.add_accessory(accessory)
fake_controller = await setup_platform(hass)
- pairing = fake_controller.add([accessory])
+ pairing = await fake_controller.add_paired_device(accessories, "00:00:00:00:00:00")
pairing.pairing_data = {"AccessoryPairingID": "00:00:00:00:00:00"}
mock_path = mock.Mock()
@@ -766,15 +551,18 @@ async def test_parse_new_homekit_json(hass):
async def test_parse_old_homekit_json(hass):
"""Test migrating original .homekit/hk-00:00:00:00:00:00 files."""
- service = FakeService("public.hap.service.lightbulb")
- on_char = service.add_characteristic("on")
- on_char.value = 1
+ accessory = Accessory.create_with_info(
+ "TestDevice", "example.com", "Test", "0001", "0.1"
+ )
+ service = accessory.add_service(ServicesTypes.LIGHTBULB)
+ on_char = service.add_char(CharacteristicsTypes.ON)
+ on_char.value = 0
- accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1")
- accessory.services.append(service)
+ accessories = Accessories()
+ accessories.add_accessory(accessory)
fake_controller = await setup_platform(hass)
- pairing = fake_controller.add([accessory])
+ pairing = await fake_controller.add_paired_device(accessories, "00:00:00:00:00:00")
pairing.pairing_data = {"AccessoryPairingID": "00:00:00:00:00:00"}
mock_path = mock.Mock()
@@ -818,15 +606,18 @@ async def test_parse_old_homekit_json(hass):
async def test_parse_overlapping_homekit_json(hass):
"""Test migrating .homekit/pairings.json files when hk- exists too."""
- service = FakeService("public.hap.service.lightbulb")
- on_char = service.add_characteristic("on")
- on_char.value = 1
+ accessory = Accessory.create_with_info(
+ "TestDevice", "example.com", "Test", "0001", "0.1"
+ )
+ service = accessory.add_service(ServicesTypes.LIGHTBULB)
+ on_char = service.add_char(CharacteristicsTypes.ON)
+ on_char.value = 0
- accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1")
- accessory.services.append(service)
+ accessories = Accessories()
+ accessories.add_accessory(accessory)
fake_controller = await setup_platform(hass)
- pairing = fake_controller.add([accessory])
+ pairing = await fake_controller.add_paired_device(accessories)
pairing.pairing_data = {"AccessoryPairingID": "00:00:00:00:00:00"}
mock_listdir = mock.Mock()
@@ -857,7 +648,6 @@ async def test_parse_overlapping_homekit_json(hass):
pairing_cls_imp = (
"homeassistant.components.homekit_controller.config_flow.IpPairing"
)
-
with mock.patch(pairing_cls_imp) as pairing_cls:
pairing_cls.return_value = pairing
with mock.patch("builtins.open", side_effect=side_effects):
@@ -877,83 +667,48 @@ async def test_parse_overlapping_homekit_json(hass):
}
-async def test_unignore_works(hass):
+async def test_unignore_works(hass, controller):
"""Test rediscovery triggered disovers work."""
- discovery_info = {
- "name": "TestDevice",
- "address": "127.0.0.1",
- "port": 8080,
- "md": "TestDevice",
- "pv": "1.0",
- "id": "00:00:00:00:00:00",
- "c#": 1,
- "s#": 1,
- "ff": 0,
- "ci": 0,
- "sf": 1,
- }
+ device = setup_mock_accessory(controller)
- pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"})
- pairing.list_accessories_and_characteristics.return_value = [
- {
- "aid": 1,
- "services": [
- {
- "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}],
- "type": "3e",
- }
- ],
- }
- ]
-
- flow = _setup_flow_handler(hass)
-
- flow.controller.pairings = {"00:00:00:00:00:00": pairing}
- flow.controller.discover.return_value = [discovery_info]
-
- result = await flow.async_step_unignore({"unique_id": "00:00:00:00:00:00"})
+ # Device is unignored
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller",
+ context={"source": "unignore"},
+ data={"unique_id": device.device_id},
+ )
assert result["type"] == "form"
assert result["step_id"] == "pair"
- assert flow.context == {
+ assert get_flow_context(hass, result) == {
"hkid": "00:00:00:00:00:00",
"title_placeholders": {"name": "TestDevice"},
"unique_id": "00:00:00:00:00:00",
+ "source": "unignore",
}
# User initiates pairing by clicking on 'configure' - device enters pairing mode and displays code
- result = await flow.async_step_pair({})
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == "form"
assert result["step_id"] == "pair"
- assert flow.controller.start_pairing.call_count == 1
# Pairing finalized
- result = await flow.async_step_pair({"pairing_code": "111-22-333"})
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"pairing_code": "111-22-333"}
+ )
assert result["type"] == "create_entry"
assert result["title"] == "Koogeek-LS1-20833F"
- assert result["data"] == pairing.pairing_data
-async def test_unignore_ignores_missing_devices(hass):
+async def test_unignore_ignores_missing_devices(hass, controller):
"""Test rediscovery triggered disovers handle devices that have gone away."""
- discovery_info = {
- "name": "TestDevice",
- "address": "127.0.0.1",
- "port": 8080,
- "md": "TestDevice",
- "pv": "1.0",
- "id": "00:00:00:00:00:00",
- "c#": 1,
- "s#": 1,
- "ff": 0,
- "ci": 0,
- "sf": 1,
- }
+ setup_mock_accessory(controller)
- flow = _setup_flow_handler(hass)
- flow.controller.discover.return_value = [discovery_info]
+ # Device is unignored
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller",
+ context={"source": "unignore"},
+ data={"unique_id": "00:00:00:00:00:01"},
+ )
- result = await flow.async_step_unignore({"unique_id": "00:00:00:00:00:01"})
assert result["type"] == "abort"
- assert flow.context == {
- "unique_id": "00:00:00:00:00:01",
- }
+ assert result["reason"] == "no_devices"
diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py
index 53245176a04..45514b29122 100644
--- a/tests/components/homekit_controller/test_cover.py
+++ b/tests/components/homekit_controller/test_cover.py
@@ -1,5 +1,8 @@
"""Basic checks for HomeKitalarm_control_panel."""
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
+from tests.components.homekit_controller.common import setup_test_component
POSITION_STATE = ("window-covering", "position.state")
POSITION_CURRENT = ("window-covering", "position.current")
@@ -19,61 +22,56 @@ DOOR_TARGET = ("garage-door-opener", "door-state.target")
DOOR_OBSTRUCTION = ("garage-door-opener", "obstruction-detected")
-def create_window_covering_service():
+def create_window_covering_service(accessory):
"""Define a window-covering characteristics as per page 219 of HAP spec."""
- service = FakeService("public.hap.service.window-covering")
+ service = accessory.add_service(ServicesTypes.WINDOW_COVERING)
- cur_state = service.add_characteristic("position.current")
+ cur_state = service.add_char(CharacteristicsTypes.POSITION_CURRENT)
cur_state.value = 0
- targ_state = service.add_characteristic("position.target")
+ targ_state = service.add_char(CharacteristicsTypes.POSITION_TARGET)
targ_state.value = 0
- position_state = service.add_characteristic("position.state")
+ position_state = service.add_char(CharacteristicsTypes.POSITION_STATE)
position_state.value = 0
- position_hold = service.add_characteristic("position.hold")
+ position_hold = service.add_char(CharacteristicsTypes.POSITION_HOLD)
position_hold.value = 0
- obstruction = service.add_characteristic("obstruction-detected")
+ obstruction = service.add_char(CharacteristicsTypes.OBSTRUCTION_DETECTED)
obstruction.value = False
- name = service.add_characteristic("name")
+ name = service.add_char(CharacteristicsTypes.NAME)
name.value = "testdevice"
return service
-def create_window_covering_service_with_h_tilt():
+def create_window_covering_service_with_h_tilt(accessory):
"""Define a window-covering characteristics as per page 219 of HAP spec."""
- service = create_window_covering_service()
+ service = create_window_covering_service(accessory)
- tilt_current = service.add_characteristic("horizontal-tilt.current")
+ tilt_current = service.add_char(CharacteristicsTypes.HORIZONTAL_TILT_CURRENT)
tilt_current.value = 0
- tilt_target = service.add_characteristic("horizontal-tilt.target")
+ tilt_target = service.add_char(CharacteristicsTypes.HORIZONTAL_TILT_TARGET)
tilt_target.value = 0
- return service
-
-def create_window_covering_service_with_v_tilt():
+def create_window_covering_service_with_v_tilt(accessory):
"""Define a window-covering characteristics as per page 219 of HAP spec."""
- service = create_window_covering_service()
+ service = create_window_covering_service(accessory)
- tilt_current = service.add_characteristic("vertical-tilt.current")
+ tilt_current = service.add_char(CharacteristicsTypes.VERTICAL_TILT_CURRENT)
tilt_current.value = 0
- tilt_target = service.add_characteristic("vertical-tilt.target")
+ tilt_target = service.add_char(CharacteristicsTypes.VERTICAL_TILT_TARGET)
tilt_target.value = 0
- return service
-
async def test_change_window_cover_state(hass, utcnow):
"""Test that we can turn a HomeKit alarm on and off again."""
- window_cover = create_window_covering_service()
- helper = await setup_test_component(hass, [window_cover])
+ helper = await setup_test_component(hass, create_window_covering_service)
await hass.services.async_call(
"cover", "open_cover", {"entity_id": helper.entity_id}, blocking=True
@@ -88,8 +86,7 @@ async def test_change_window_cover_state(hass, utcnow):
async def test_read_window_cover_state(hass, utcnow):
"""Test that we can read the state of a HomeKit alarm accessory."""
- window_cover = create_window_covering_service()
- helper = await setup_test_component(hass, [window_cover])
+ helper = await setup_test_component(hass, create_window_covering_service)
helper.characteristics[POSITION_STATE].value = 0
state = await helper.poll_and_get_state()
@@ -110,8 +107,9 @@ async def test_read_window_cover_state(hass, utcnow):
async def test_read_window_cover_tilt_horizontal(hass, utcnow):
"""Test that horizontal tilt is handled correctly."""
- window_cover = create_window_covering_service_with_h_tilt()
- helper = await setup_test_component(hass, [window_cover])
+ helper = await setup_test_component(
+ hass, create_window_covering_service_with_h_tilt
+ )
helper.characteristics[H_TILT_CURRENT].value = 75
state = await helper.poll_and_get_state()
@@ -120,8 +118,9 @@ async def test_read_window_cover_tilt_horizontal(hass, utcnow):
async def test_read_window_cover_tilt_vertical(hass, utcnow):
"""Test that vertical tilt is handled correctly."""
- window_cover = create_window_covering_service_with_v_tilt()
- helper = await setup_test_component(hass, [window_cover])
+ helper = await setup_test_component(
+ hass, create_window_covering_service_with_v_tilt
+ )
helper.characteristics[V_TILT_CURRENT].value = 75
state = await helper.poll_and_get_state()
@@ -130,8 +129,9 @@ async def test_read_window_cover_tilt_vertical(hass, utcnow):
async def test_write_window_cover_tilt_horizontal(hass, utcnow):
"""Test that horizontal tilt is written correctly."""
- window_cover = create_window_covering_service_with_h_tilt()
- helper = await setup_test_component(hass, [window_cover])
+ helper = await setup_test_component(
+ hass, create_window_covering_service_with_h_tilt
+ )
await hass.services.async_call(
"cover",
@@ -144,8 +144,9 @@ async def test_write_window_cover_tilt_horizontal(hass, utcnow):
async def test_write_window_cover_tilt_vertical(hass, utcnow):
"""Test that vertical tilt is written correctly."""
- window_cover = create_window_covering_service_with_v_tilt()
- helper = await setup_test_component(hass, [window_cover])
+ helper = await setup_test_component(
+ hass, create_window_covering_service_with_v_tilt
+ )
await hass.services.async_call(
"cover",
@@ -158,8 +159,9 @@ async def test_write_window_cover_tilt_vertical(hass, utcnow):
async def test_window_cover_stop(hass, utcnow):
"""Test that vertical tilt is written correctly."""
- window_cover = create_window_covering_service_with_v_tilt()
- helper = await setup_test_component(hass, [window_cover])
+ helper = await setup_test_component(
+ hass, create_window_covering_service_with_v_tilt
+ )
await hass.services.async_call(
"cover", "stop_cover", {"entity_id": helper.entity_id}, blocking=True
@@ -167,20 +169,20 @@ async def test_window_cover_stop(hass, utcnow):
assert helper.characteristics[POSITION_HOLD].value == 1
-def create_garage_door_opener_service():
+def create_garage_door_opener_service(accessory):
"""Define a garage-door-opener chars as per page 217 of HAP spec."""
- service = FakeService("public.hap.service.garage-door-opener")
+ service = accessory.add_service(ServicesTypes.GARAGE_DOOR_OPENER)
- cur_state = service.add_characteristic("door-state.current")
+ cur_state = service.add_char(CharacteristicsTypes.DOOR_STATE_CURRENT)
cur_state.value = 0
- targ_state = service.add_characteristic("door-state.target")
- targ_state.value = 0
+ cur_state = service.add_char(CharacteristicsTypes.DOOR_STATE_TARGET)
+ cur_state.value = 0
- obstruction = service.add_characteristic("obstruction-detected")
+ obstruction = service.add_char(CharacteristicsTypes.OBSTRUCTION_DETECTED)
obstruction.value = False
- name = service.add_characteristic("name")
+ name = service.add_char(CharacteristicsTypes.NAME)
name.value = "testdevice"
return service
@@ -188,8 +190,7 @@ def create_garage_door_opener_service():
async def test_change_door_state(hass, utcnow):
"""Test that we can turn open and close a HomeKit garage door."""
- door = create_garage_door_opener_service()
- helper = await setup_test_component(hass, [door])
+ helper = await setup_test_component(hass, create_garage_door_opener_service)
await hass.services.async_call(
"cover", "open_cover", {"entity_id": helper.entity_id}, blocking=True
@@ -204,8 +205,7 @@ async def test_change_door_state(hass, utcnow):
async def test_read_door_state(hass, utcnow):
"""Test that we can read the state of a HomeKit garage door."""
- door = create_garage_door_opener_service()
- helper = await setup_test_component(hass, [door])
+ helper = await setup_test_component(hass, create_garage_door_opener_service)
helper.characteristics[DOOR_CURRENT].value = 0
state = await helper.poll_and_get_state()
diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py
index fe97451cfbb..fd24f5215da 100644
--- a/tests/components/homekit_controller/test_fan.py
+++ b/tests/components/homekit_controller/test_fan.py
@@ -1,5 +1,8 @@
"""Basic checks for HomeKit motion sensors and contact sensors."""
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
+from tests.components.homekit_controller.common import setup_test_component
V1_ON = ("fan", "on")
V1_ROTATION_DIRECTION = ("fan", "rotation.direction")
@@ -11,50 +14,45 @@ V2_ROTATION_SPEED = ("fanv2", "rotation.speed")
V2_SWING_MODE = ("fanv2", "swing-mode")
-def create_fan_service():
+def create_fan_service(accessory):
"""
Define fan v1 characteristics as per HAP spec.
This service is no longer documented in R2 of the public HAP spec but existing
devices out there use it (like the SIMPLEconnect fan)
"""
- service = FakeService("public.hap.service.fan")
+ service = accessory.add_service(ServicesTypes.FAN)
- cur_state = service.add_characteristic("on")
+ cur_state = service.add_char(CharacteristicsTypes.ON)
cur_state.value = 0
- cur_state = service.add_characteristic("rotation.direction")
- cur_state.value = 0
+ direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION)
+ direction.value = 0
- cur_state = service.add_characteristic("rotation.speed")
- cur_state.value = 0
-
- return service
+ speed = service.add_char(CharacteristicsTypes.ROTATION_SPEED)
+ speed.value = 0
-def create_fanv2_service():
+def create_fanv2_service(accessory):
"""Define fan v2 characteristics as per HAP spec."""
- service = FakeService("public.hap.service.fanv2")
+ service = accessory.add_service(ServicesTypes.FAN_V2)
- cur_state = service.add_characteristic("active")
+ cur_state = service.add_char(CharacteristicsTypes.ACTIVE)
cur_state.value = 0
- cur_state = service.add_characteristic("rotation.direction")
- cur_state.value = 0
+ direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION)
+ direction.value = 0
- cur_state = service.add_characteristic("rotation.speed")
- cur_state.value = 0
+ speed = service.add_char(CharacteristicsTypes.ROTATION_SPEED)
+ speed.value = 0
- cur_state = service.add_characteristic("swing-mode")
- cur_state.value = 0
-
- return service
+ swing_mode = service.add_char(CharacteristicsTypes.SWING_MODE)
+ swing_mode.value = 0
async def test_fan_read_state(hass, utcnow):
"""Test that we can read the state of a HomeKit fan accessory."""
- sensor = create_fan_service()
- helper = await setup_test_component(hass, [sensor])
+ helper = await setup_test_component(hass, create_fan_service)
helper.characteristics[V1_ON].value = False
state = await helper.poll_and_get_state()
@@ -67,8 +65,7 @@ async def test_fan_read_state(hass, utcnow):
async def test_turn_on(hass, utcnow):
"""Test that we can turn a fan on."""
- fan = create_fan_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fan_service)
await hass.services.async_call(
"fan",
@@ -100,8 +97,7 @@ async def test_turn_on(hass, utcnow):
async def test_turn_off(hass, utcnow):
"""Test that we can turn a fan off."""
- fan = create_fan_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fan_service)
helper.characteristics[V1_ON].value = 1
@@ -113,8 +109,7 @@ async def test_turn_off(hass, utcnow):
async def test_set_speed(hass, utcnow):
"""Test that we set fan speed."""
- fan = create_fan_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fan_service)
helper.characteristics[V1_ON].value = 1
@@ -153,8 +148,7 @@ async def test_set_speed(hass, utcnow):
async def test_speed_read(hass, utcnow):
"""Test that we can read a fans oscillation."""
- fan = create_fan_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fan_service)
helper.characteristics[V1_ON].value = 1
helper.characteristics[V1_ROTATION_SPEED].value = 100
@@ -177,8 +171,7 @@ async def test_speed_read(hass, utcnow):
async def test_set_direction(hass, utcnow):
"""Test that we can set fan spin direction."""
- fan = create_fan_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fan_service)
await hass.services.async_call(
"fan",
@@ -199,8 +192,7 @@ async def test_set_direction(hass, utcnow):
async def test_direction_read(hass, utcnow):
"""Test that we can read a fans oscillation."""
- fan = create_fan_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fan_service)
helper.characteristics[V1_ROTATION_DIRECTION].value = 0
state = await helper.poll_and_get_state()
@@ -213,8 +205,7 @@ async def test_direction_read(hass, utcnow):
async def test_fanv2_read_state(hass, utcnow):
"""Test that we can read the state of a HomeKit fan accessory."""
- sensor = create_fanv2_service()
- helper = await setup_test_component(hass, [sensor])
+ helper = await setup_test_component(hass, create_fanv2_service)
helper.characteristics[V2_ACTIVE].value = False
state = await helper.poll_and_get_state()
@@ -227,8 +218,7 @@ async def test_fanv2_read_state(hass, utcnow):
async def test_v2_turn_on(hass, utcnow):
"""Test that we can turn a fan on."""
- fan = create_fanv2_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fanv2_service)
await hass.services.async_call(
"fan",
@@ -260,8 +250,7 @@ async def test_v2_turn_on(hass, utcnow):
async def test_v2_turn_off(hass, utcnow):
"""Test that we can turn a fan off."""
- fan = create_fanv2_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fanv2_service)
helper.characteristics[V2_ACTIVE].value = 1
@@ -273,8 +262,7 @@ async def test_v2_turn_off(hass, utcnow):
async def test_v2_set_speed(hass, utcnow):
"""Test that we set fan speed."""
- fan = create_fanv2_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fanv2_service)
helper.characteristics[V2_ACTIVE].value = 1
@@ -313,8 +301,7 @@ async def test_v2_set_speed(hass, utcnow):
async def test_v2_speed_read(hass, utcnow):
"""Test that we can read a fans oscillation."""
- fan = create_fanv2_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fanv2_service)
helper.characteristics[V2_ACTIVE].value = 1
helper.characteristics[V2_ROTATION_SPEED].value = 100
@@ -337,8 +324,7 @@ async def test_v2_speed_read(hass, utcnow):
async def test_v2_set_direction(hass, utcnow):
"""Test that we can set fan spin direction."""
- fan = create_fanv2_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fanv2_service)
await hass.services.async_call(
"fan",
@@ -359,8 +345,7 @@ async def test_v2_set_direction(hass, utcnow):
async def test_v2_direction_read(hass, utcnow):
"""Test that we can read a fans oscillation."""
- fan = create_fanv2_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fanv2_service)
helper.characteristics[V2_ROTATION_DIRECTION].value = 0
state = await helper.poll_and_get_state()
@@ -373,8 +358,7 @@ async def test_v2_direction_read(hass, utcnow):
async def test_v2_oscillate(hass, utcnow):
"""Test that we can control a fans oscillation."""
- fan = create_fanv2_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fanv2_service)
await hass.services.async_call(
"fan",
@@ -395,8 +379,7 @@ async def test_v2_oscillate(hass, utcnow):
async def test_v2_oscillate_read(hass, utcnow):
"""Test that we can read a fans oscillation."""
- fan = create_fanv2_service()
- helper = await setup_test_component(hass, [fan])
+ helper = await setup_test_component(hass, create_fanv2_service)
helper.characteristics[V2_SWING_MODE].value = 0
state = await helper.poll_and_get_state()
diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py
index b558160a9f2..e443e36b910 100644
--- a/tests/components/homekit_controller/test_light.py
+++ b/tests/components/homekit_controller/test_light.py
@@ -1,7 +1,13 @@
"""Basic checks for HomeKitSwitch."""
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
from homeassistant.components.homekit_controller.const import KNOWN_DEVICES
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from tests.components.homekit_controller.common import setup_test_component
+
+LIGHT_BULB_NAME = "Light Bulb"
+LIGHT_BULB_ENTITY_ID = "light.testdevice"
LIGHT_ON = ("lightbulb", "on")
LIGHT_BRIGHTNESS = ("lightbulb", "brightness")
@@ -10,37 +16,37 @@ LIGHT_SATURATION = ("lightbulb", "saturation")
LIGHT_COLOR_TEMP = ("lightbulb", "color-temperature")
-def create_lightbulb_service():
+def create_lightbulb_service(accessory):
"""Define lightbulb characteristics."""
- service = FakeService("public.hap.service.lightbulb")
+ service = accessory.add_service(ServicesTypes.LIGHTBULB, name=LIGHT_BULB_NAME)
- on_char = service.add_characteristic("on")
+ on_char = service.add_char(CharacteristicsTypes.ON)
on_char.value = 0
- brightness = service.add_characteristic("brightness")
+ brightness = service.add_char(CharacteristicsTypes.BRIGHTNESS)
brightness.value = 0
return service
-def create_lightbulb_service_with_hs():
+def create_lightbulb_service_with_hs(accessory):
"""Define a lightbulb service with hue + saturation."""
- service = create_lightbulb_service()
+ service = create_lightbulb_service(accessory)
- hue = service.add_characteristic("hue")
+ hue = service.add_char(CharacteristicsTypes.HUE)
hue.value = 0
- saturation = service.add_characteristic("saturation")
+ saturation = service.add_char(CharacteristicsTypes.SATURATION)
saturation.value = 0
return service
-def create_lightbulb_service_with_color_temp():
+def create_lightbulb_service_with_color_temp(accessory):
"""Define a lightbulb service with color temp."""
- service = create_lightbulb_service()
+ service = create_lightbulb_service(accessory)
- color_temp = service.add_characteristic("color-temperature")
+ color_temp = service.add_char(CharacteristicsTypes.COLOR_TEMPERATURE)
color_temp.value = 0
return service
@@ -48,8 +54,7 @@ def create_lightbulb_service_with_color_temp():
async def test_switch_change_light_state(hass, utcnow):
"""Test that we can turn a HomeKit light on and off again."""
- bulb = create_lightbulb_service_with_hs()
- helper = await setup_test_component(hass, [bulb])
+ helper = await setup_test_component(hass, create_lightbulb_service_with_hs)
await hass.services.async_call(
"light",
@@ -71,8 +76,7 @@ async def test_switch_change_light_state(hass, utcnow):
async def test_switch_change_light_state_color_temp(hass, utcnow):
"""Test that we can turn change color_temp."""
- bulb = create_lightbulb_service_with_color_temp()
- helper = await setup_test_component(hass, [bulb])
+ helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp)
await hass.services.async_call(
"light",
@@ -87,8 +91,7 @@ async def test_switch_change_light_state_color_temp(hass, utcnow):
async def test_switch_read_light_state(hass, utcnow):
"""Test that we can read the state of a HomeKit light accessory."""
- bulb = create_lightbulb_service_with_hs()
- helper = await setup_test_component(hass, [bulb])
+ helper = await setup_test_component(hass, create_lightbulb_service_with_hs)
# Initial state is that the light is off
state = await helper.poll_and_get_state()
@@ -110,10 +113,38 @@ async def test_switch_read_light_state(hass, utcnow):
assert state.state == "off"
+async def test_switch_push_light_state(hass, utcnow):
+ """Test that we can read the state of a HomeKit light accessory."""
+ helper = await setup_test_component(hass, create_lightbulb_service_with_hs)
+
+ # Initial state is that the light is off
+ state = hass.states.get(LIGHT_BULB_ENTITY_ID)
+ assert state.state == "off"
+
+ await helper.update_named_service(
+ LIGHT_BULB_NAME,
+ {
+ CharacteristicsTypes.ON: True,
+ CharacteristicsTypes.BRIGHTNESS: 100,
+ CharacteristicsTypes.HUE: 4,
+ CharacteristicsTypes.SATURATION: 5,
+ },
+ )
+
+ state = hass.states.get(LIGHT_BULB_ENTITY_ID)
+ assert state.state == "on"
+ assert state.attributes["brightness"] == 255
+ assert state.attributes["hs_color"] == (4, 5)
+
+ # Simulate that device switched off in the real world not via HA
+ await helper.update_named_service(LIGHT_BULB_NAME, {CharacteristicsTypes.ON: False})
+ state = hass.states.get(LIGHT_BULB_ENTITY_ID)
+ assert state.state == "off"
+
+
async def test_switch_read_light_state_color_temp(hass, utcnow):
"""Test that we can read the color_temp of a light accessory."""
- bulb = create_lightbulb_service_with_color_temp()
- helper = await setup_test_component(hass, [bulb])
+ helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp)
# Initial state is that the light is off
state = await helper.poll_and_get_state()
@@ -130,10 +161,32 @@ async def test_switch_read_light_state_color_temp(hass, utcnow):
assert state.attributes["color_temp"] == 400
+async def test_switch_push_light_state_color_temp(hass, utcnow):
+ """Test that we can read the state of a HomeKit light accessory."""
+ helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp)
+
+ # Initial state is that the light is off
+ state = hass.states.get(LIGHT_BULB_ENTITY_ID)
+ assert state.state == "off"
+
+ await helper.update_named_service(
+ LIGHT_BULB_NAME,
+ {
+ CharacteristicsTypes.ON: True,
+ CharacteristicsTypes.BRIGHTNESS: 100,
+ CharacteristicsTypes.COLOR_TEMPERATURE: 400,
+ },
+ )
+
+ state = hass.states.get(LIGHT_BULB_ENTITY_ID)
+ assert state.state == "on"
+ assert state.attributes["brightness"] == 255
+ assert state.attributes["color_temp"] == 400
+
+
async def test_light_becomes_unavailable_but_recovers(hass, utcnow):
"""Test transition to and from unavailable state."""
- bulb = create_lightbulb_service_with_color_temp()
- helper = await setup_test_component(hass, [bulb])
+ helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp)
# Initial state is that the light is off
state = await helper.poll_and_get_state()
@@ -158,8 +211,7 @@ async def test_light_becomes_unavailable_but_recovers(hass, utcnow):
async def test_light_unloaded(hass, utcnow):
"""Test entity and HKDevice are correctly unloaded."""
- bulb = create_lightbulb_service_with_color_temp()
- helper = await setup_test_component(hass, [bulb])
+ helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp)
# Initial state is that the light is off
state = await helper.poll_and_get_state()
diff --git a/tests/components/homekit_controller/test_lock.py b/tests/components/homekit_controller/test_lock.py
index d47b77a37eb..197b7b3c3b9 100644
--- a/tests/components/homekit_controller/test_lock.py
+++ b/tests/components/homekit_controller/test_lock.py
@@ -1,25 +1,28 @@
"""Basic checks for HomeKitLock."""
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
+from tests.components.homekit_controller.common import setup_test_component
LOCK_CURRENT_STATE = ("lock-mechanism", "lock-mechanism.current-state")
LOCK_TARGET_STATE = ("lock-mechanism", "lock-mechanism.target-state")
-def create_lock_service():
+def create_lock_service(accessory):
"""Define a lock characteristics as per page 219 of HAP spec."""
- service = FakeService("public.hap.service.lock-mechanism")
+ service = accessory.add_service(ServicesTypes.LOCK_MECHANISM)
- cur_state = service.add_characteristic("lock-mechanism.current-state")
+ cur_state = service.add_char(CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE)
cur_state.value = 0
- targ_state = service.add_characteristic("lock-mechanism.target-state")
+ targ_state = service.add_char(CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE)
targ_state.value = 0
# According to the spec, a battery-level characteristic is normally
# part of a separate service. However as the code was written (which
# predates this test) the battery level would have to be part of the lock
# service as it is here.
- targ_state = service.add_characteristic("battery-level")
+ targ_state = service.add_char(CharacteristicsTypes.BATTERY_LEVEL)
targ_state.value = 50
return service
@@ -27,8 +30,7 @@ def create_lock_service():
async def test_switch_change_lock_state(hass, utcnow):
"""Test that we can turn a HomeKit lock on and off again."""
- lock = create_lock_service()
- helper = await setup_test_component(hass, [lock])
+ helper = await setup_test_component(hass, create_lock_service)
await hass.services.async_call(
"lock", "lock", {"entity_id": "lock.testdevice"}, blocking=True
@@ -43,8 +45,7 @@ async def test_switch_change_lock_state(hass, utcnow):
async def test_switch_read_lock_state(hass, utcnow):
"""Test that we can read the state of a HomeKit lock accessory."""
- lock = create_lock_service()
- helper = await setup_test_component(hass, [lock])
+ helper = await setup_test_component(hass, create_lock_service)
helper.characteristics[LOCK_CURRENT_STATE].value = 0
helper.characteristics[LOCK_TARGET_STATE].value = 0
diff --git a/tests/components/homekit_controller/test_media_player.py b/tests/components/homekit_controller/test_media_player.py
new file mode 100644
index 00000000000..44c53af02da
--- /dev/null
+++ b/tests/components/homekit_controller/test_media_player.py
@@ -0,0 +1,266 @@
+"""Basic checks for HomeKit motion sensors and contact sensors."""
+from aiohomekit.model.characteristics import (
+ CharacteristicPermissions,
+ CharacteristicsTypes,
+)
+from aiohomekit.model.services import ServicesTypes
+import pytest
+
+from tests.components.homekit_controller.common import setup_test_component
+
+CURRENT_MEDIA_STATE = ("television", "current-media-state")
+TARGET_MEDIA_STATE = ("television", "target-media-state")
+REMOTE_KEY = ("television", "remote-key")
+ACTIVE_IDENTIFIER = ("television", "active-identifier")
+
+
+def create_tv_service(accessory):
+ """
+ Define tv characteristics.
+
+ The TV is not currently documented publicly - this is based on observing really TV's that have HomeKit support.
+ """
+ tv_service = accessory.add_service(ServicesTypes.TELEVISION)
+
+ tv_service.add_char(CharacteristicsTypes.ACTIVE, value=True)
+
+ cur_state = tv_service.add_char(CharacteristicsTypes.CURRENT_MEDIA_STATE)
+ cur_state.value = 0
+
+ remote = tv_service.add_char(CharacteristicsTypes.REMOTE_KEY)
+ remote.value = None
+ remote.perms.append(CharacteristicPermissions.paired_write)
+
+ # Add a HDMI 1 channel
+ input_source_1 = accessory.add_service(ServicesTypes.INPUT_SOURCE)
+ input_source_1.add_char(CharacteristicsTypes.IDENTIFIER, value=1)
+ input_source_1.add_char(CharacteristicsTypes.CONFIGURED_NAME, value="HDMI 1")
+ tv_service.add_linked_service(input_source_1)
+
+ # Add a HDMI 2 channel
+ input_source_2 = accessory.add_service(ServicesTypes.INPUT_SOURCE)
+ input_source_2.add_char(CharacteristicsTypes.IDENTIFIER, value=2)
+ input_source_2.add_char(CharacteristicsTypes.CONFIGURED_NAME, value="HDMI 2")
+ tv_service.add_linked_service(input_source_2)
+
+ # Support switching channels
+ active_identifier = tv_service.add_char(CharacteristicsTypes.ACTIVE_IDENTIFIER)
+ active_identifier.value = 1
+ active_identifier.perms.append(CharacteristicPermissions.paired_write)
+
+ return tv_service
+
+
+def create_tv_service_with_target_media_state(accessory):
+ """Define a TV service that can play/pause/stop without generate remote events."""
+ service = create_tv_service(accessory)
+
+ tms = service.add_char(CharacteristicsTypes.TARGET_MEDIA_STATE)
+ tms.value = None
+ tms.perms.append(CharacteristicPermissions.paired_write)
+
+ return service
+
+
+async def test_tv_read_state(hass, utcnow):
+ """Test that we can read the state of a HomeKit fan accessory."""
+ helper = await setup_test_component(hass, create_tv_service)
+
+ helper.characteristics[CURRENT_MEDIA_STATE].value = 0
+ state = await helper.poll_and_get_state()
+ assert state.state == "playing"
+
+ helper.characteristics[CURRENT_MEDIA_STATE].value = 1
+ state = await helper.poll_and_get_state()
+ assert state.state == "paused"
+
+ helper.characteristics[CURRENT_MEDIA_STATE].value = 2
+ state = await helper.poll_and_get_state()
+ assert state.state == "idle"
+
+
+async def test_tv_read_sources(hass, utcnow):
+ """Test that we can read the input source of a HomeKit TV."""
+ helper = await setup_test_component(hass, create_tv_service)
+
+ state = await helper.poll_and_get_state()
+ assert state.attributes["source"] == "HDMI 1"
+ assert state.attributes["source_list"] == ["HDMI 1", "HDMI 2"]
+
+
+async def test_play_remote_key(hass, utcnow):
+ """Test that we can play media on a media player."""
+ helper = await setup_test_component(hass, create_tv_service)
+
+ helper.characteristics[CURRENT_MEDIA_STATE].value = 1
+ await helper.poll_and_get_state()
+
+ await hass.services.async_call(
+ "media_player",
+ "media_play",
+ {"entity_id": "media_player.testdevice"},
+ blocking=True,
+ )
+ assert helper.characteristics[REMOTE_KEY].value == 11
+
+ # Second time should be a no-op
+ helper.characteristics[CURRENT_MEDIA_STATE].value = 0
+ await helper.poll_and_get_state()
+
+ helper.characteristics[REMOTE_KEY].value = None
+ await hass.services.async_call(
+ "media_player",
+ "media_play",
+ {"entity_id": "media_player.testdevice"},
+ blocking=True,
+ )
+ assert helper.characteristics[REMOTE_KEY].value is None
+
+
+async def test_pause_remote_key(hass, utcnow):
+ """Test that we can pause a media player."""
+ helper = await setup_test_component(hass, create_tv_service)
+
+ helper.characteristics[CURRENT_MEDIA_STATE].value = 0
+ await helper.poll_and_get_state()
+
+ await hass.services.async_call(
+ "media_player",
+ "media_pause",
+ {"entity_id": "media_player.testdevice"},
+ blocking=True,
+ )
+ assert helper.characteristics[REMOTE_KEY].value == 11
+
+ # Second time should be a no-op
+ helper.characteristics[CURRENT_MEDIA_STATE].value = 1
+ await helper.poll_and_get_state()
+
+ helper.characteristics[REMOTE_KEY].value = None
+ await hass.services.async_call(
+ "media_player",
+ "media_pause",
+ {"entity_id": "media_player.testdevice"},
+ blocking=True,
+ )
+ assert helper.characteristics[REMOTE_KEY].value is None
+
+
+async def test_play(hass, utcnow):
+ """Test that we can play media on a media player."""
+ helper = await setup_test_component(hass, create_tv_service_with_target_media_state)
+
+ helper.characteristics[CURRENT_MEDIA_STATE].value = 1
+ await helper.poll_and_get_state()
+
+ await hass.services.async_call(
+ "media_player",
+ "media_play",
+ {"entity_id": "media_player.testdevice"},
+ blocking=True,
+ )
+ assert helper.characteristics[REMOTE_KEY].value is None
+ assert helper.characteristics[TARGET_MEDIA_STATE].value == 0
+
+ # Second time should be a no-op
+ helper.characteristics[CURRENT_MEDIA_STATE].value = 0
+ await helper.poll_and_get_state()
+
+ helper.characteristics[TARGET_MEDIA_STATE].value = None
+ await hass.services.async_call(
+ "media_player",
+ "media_play",
+ {"entity_id": "media_player.testdevice"},
+ blocking=True,
+ )
+ assert helper.characteristics[REMOTE_KEY].value is None
+ assert helper.characteristics[TARGET_MEDIA_STATE].value is None
+
+
+async def test_pause(hass, utcnow):
+ """Test that we can turn pause a media player."""
+ helper = await setup_test_component(hass, create_tv_service_with_target_media_state)
+
+ helper.characteristics[CURRENT_MEDIA_STATE].value = 0
+ await helper.poll_and_get_state()
+
+ await hass.services.async_call(
+ "media_player",
+ "media_pause",
+ {"entity_id": "media_player.testdevice"},
+ blocking=True,
+ )
+ assert helper.characteristics[REMOTE_KEY].value is None
+ assert helper.characteristics[TARGET_MEDIA_STATE].value == 1
+
+ # Second time should be a no-op
+ helper.characteristics[CURRENT_MEDIA_STATE].value = 1
+ await helper.poll_and_get_state()
+
+ helper.characteristics[REMOTE_KEY].value = None
+ await hass.services.async_call(
+ "media_player",
+ "media_pause",
+ {"entity_id": "media_player.testdevice"},
+ blocking=True,
+ )
+ assert helper.characteristics[REMOTE_KEY].value is None
+
+
+async def test_stop(hass, utcnow):
+ """Test that we can stop a media player."""
+ helper = await setup_test_component(hass, create_tv_service_with_target_media_state)
+
+ await hass.services.async_call(
+ "media_player",
+ "media_stop",
+ {"entity_id": "media_player.testdevice"},
+ blocking=True,
+ )
+ assert helper.characteristics[TARGET_MEDIA_STATE].value == 2
+
+ # Second time should be a no-op
+ helper.characteristics[CURRENT_MEDIA_STATE].value = 2
+ await helper.poll_and_get_state()
+
+ helper.characteristics[TARGET_MEDIA_STATE].value = None
+ await hass.services.async_call(
+ "media_player",
+ "media_stop",
+ {"entity_id": "media_player.testdevice"},
+ blocking=True,
+ )
+ assert helper.characteristics[REMOTE_KEY].value is None
+ assert helper.characteristics[TARGET_MEDIA_STATE].value is None
+
+
+async def test_tv_set_source(hass, utcnow):
+ """Test that we can set the input source of a HomeKit TV."""
+ helper = await setup_test_component(hass, create_tv_service)
+
+ await hass.services.async_call(
+ "media_player",
+ "select_source",
+ {"entity_id": "media_player.testdevice", "source": "HDMI 2"},
+ blocking=True,
+ )
+ assert helper.characteristics[ACTIVE_IDENTIFIER].value == 2
+
+ state = await helper.poll_and_get_state()
+ assert state.attributes["source"] == "HDMI 2"
+
+
+async def test_tv_set_source_fail(hass, utcnow):
+ """Test that we can set the input source of a HomeKit TV."""
+ helper = await setup_test_component(hass, create_tv_service)
+
+ with pytest.raises(ValueError):
+ await hass.services.async_call(
+ "media_player",
+ "select_source",
+ {"entity_id": "media_player.testdevice", "source": "HDMI 999"},
+ blocking=True,
+ )
+
+ state = await helper.poll_and_get_state()
+ assert state.attributes["source"] == "HDMI 1"
diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py
index f9d84b06996..8b0528ea46d 100644
--- a/tests/components/homekit_controller/test_sensor.py
+++ b/tests/components/homekit_controller/test_sensor.py
@@ -1,5 +1,15 @@
"""Basic checks for HomeKit sensor."""
-from tests.components.homekit_controller.common import FakeService, setup_test_component
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
+from homeassistant.const import (
+ DEVICE_CLASS_BATTERY,
+ DEVICE_CLASS_HUMIDITY,
+ DEVICE_CLASS_ILLUMINANCE,
+ DEVICE_CLASS_TEMPERATURE,
+)
+
+from tests.components.homekit_controller.common import setup_test_component
TEMPERATURE = ("temperature", "temperature.current")
HUMIDITY = ("humidity", "relative-humidity.current")
@@ -10,57 +20,49 @@ CHARGING_STATE = ("battery", "charging-state")
LO_BATT = ("battery", "status-lo-batt")
-def create_temperature_sensor_service():
+def create_temperature_sensor_service(accessory):
"""Define temperature characteristics."""
- service = FakeService("public.hap.service.sensor.temperature")
+ service = accessory.add_service(ServicesTypes.TEMPERATURE_SENSOR)
- cur_state = service.add_characteristic("temperature.current")
+ cur_state = service.add_char(CharacteristicsTypes.TEMPERATURE_CURRENT)
cur_state.value = 0
- return service
-
-def create_humidity_sensor_service():
+def create_humidity_sensor_service(accessory):
"""Define humidity characteristics."""
- service = FakeService("public.hap.service.sensor.humidity")
+ service = accessory.add_service(ServicesTypes.HUMIDITY_SENSOR)
- cur_state = service.add_characteristic("relative-humidity.current")
+ cur_state = service.add_char(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT)
cur_state.value = 0
- return service
-
-def create_light_level_sensor_service():
+def create_light_level_sensor_service(accessory):
"""Define light level characteristics."""
- service = FakeService("public.hap.service.sensor.light")
+ service = accessory.add_service(ServicesTypes.LIGHT_SENSOR)
- cur_state = service.add_characteristic("light-level.current")
+ cur_state = service.add_char(CharacteristicsTypes.LIGHT_LEVEL_CURRENT)
cur_state.value = 0
- return service
-
-def create_carbon_dioxide_level_sensor_service():
+def create_carbon_dioxide_level_sensor_service(accessory):
"""Define carbon dioxide level characteristics."""
- service = FakeService("public.hap.service.sensor.carbon-dioxide")
+ service = accessory.add_service(ServicesTypes.CARBON_DIOXIDE_SENSOR)
- cur_state = service.add_characteristic("carbon-dioxide.level")
+ cur_state = service.add_char(CharacteristicsTypes.CARBON_DIOXIDE_LEVEL)
cur_state.value = 0
- return service
-
-def create_battery_level_sensor():
+def create_battery_level_sensor(accessory):
"""Define battery level characteristics."""
- service = FakeService("public.hap.service.battery")
+ service = accessory.add_service(ServicesTypes.BATTERY_SERVICE)
- cur_state = service.add_characteristic("battery-level")
+ cur_state = service.add_char(CharacteristicsTypes.BATTERY_LEVEL)
cur_state.value = 100
- low_battery = service.add_characteristic("status-lo-batt")
+ low_battery = service.add_char(CharacteristicsTypes.STATUS_LO_BATT)
low_battery.value = 0
- charging_state = service.add_characteristic("charging-state")
+ charging_state = service.add_char(CharacteristicsTypes.CHARGING_STATE)
charging_state.value = 0
return service
@@ -68,8 +70,9 @@ def create_battery_level_sensor():
async def test_temperature_sensor_read_state(hass, utcnow):
"""Test reading the state of a HomeKit temperature sensor accessory."""
- sensor = create_temperature_sensor_service()
- helper = await setup_test_component(hass, [sensor], suffix="temperature")
+ helper = await setup_test_component(
+ hass, create_temperature_sensor_service, suffix="temperature"
+ )
helper.characteristics[TEMPERATURE].value = 10
state = await helper.poll_and_get_state()
@@ -79,11 +82,14 @@ async def test_temperature_sensor_read_state(hass, utcnow):
state = await helper.poll_and_get_state()
assert state.state == "20"
+ assert state.attributes["device_class"] == DEVICE_CLASS_TEMPERATURE
+
async def test_humidity_sensor_read_state(hass, utcnow):
"""Test reading the state of a HomeKit humidity sensor accessory."""
- sensor = create_humidity_sensor_service()
- helper = await setup_test_component(hass, [sensor], suffix="humidity")
+ helper = await setup_test_component(
+ hass, create_humidity_sensor_service, suffix="humidity"
+ )
helper.characteristics[HUMIDITY].value = 10
state = await helper.poll_and_get_state()
@@ -93,11 +99,14 @@ async def test_humidity_sensor_read_state(hass, utcnow):
state = await helper.poll_and_get_state()
assert state.state == "20"
+ assert state.attributes["device_class"] == DEVICE_CLASS_HUMIDITY
+
async def test_light_level_sensor_read_state(hass, utcnow):
"""Test reading the state of a HomeKit temperature sensor accessory."""
- sensor = create_light_level_sensor_service()
- helper = await setup_test_component(hass, [sensor], suffix="light_level")
+ helper = await setup_test_component(
+ hass, create_light_level_sensor_service, suffix="light_level"
+ )
helper.characteristics[LIGHT_LEVEL].value = 10
state = await helper.poll_and_get_state()
@@ -107,11 +116,14 @@ async def test_light_level_sensor_read_state(hass, utcnow):
state = await helper.poll_and_get_state()
assert state.state == "20"
+ assert state.attributes["device_class"] == DEVICE_CLASS_ILLUMINANCE
+
async def test_carbon_dioxide_level_sensor_read_state(hass, utcnow):
"""Test reading the state of a HomeKit carbon dioxide sensor accessory."""
- sensor = create_carbon_dioxide_level_sensor_service()
- helper = await setup_test_component(hass, [sensor], suffix="co2")
+ helper = await setup_test_component(
+ hass, create_carbon_dioxide_level_sensor_service, suffix="co2"
+ )
helper.characteristics[CARBON_DIOXIDE_LEVEL].value = 10
state = await helper.poll_and_get_state()
@@ -124,8 +136,9 @@ async def test_carbon_dioxide_level_sensor_read_state(hass, utcnow):
async def test_battery_level_sensor(hass, utcnow):
"""Test reading the state of a HomeKit battery level sensor."""
- sensor = create_battery_level_sensor()
- helper = await setup_test_component(hass, [sensor], suffix="battery")
+ helper = await setup_test_component(
+ hass, create_battery_level_sensor, suffix="battery"
+ )
helper.characteristics[BATTERY_LEVEL].value = 100
state = await helper.poll_and_get_state()
@@ -137,11 +150,14 @@ async def test_battery_level_sensor(hass, utcnow):
assert state.state == "20"
assert state.attributes["icon"] == "mdi:battery-20"
+ assert state.attributes["device_class"] == DEVICE_CLASS_BATTERY
+
async def test_battery_charging(hass, utcnow):
"""Test reading the state of a HomeKit battery's charging state."""
- sensor = create_battery_level_sensor()
- helper = await setup_test_component(hass, [sensor], suffix="battery")
+ helper = await setup_test_component(
+ hass, create_battery_level_sensor, suffix="battery"
+ )
helper.characteristics[BATTERY_LEVEL].value = 0
helper.characteristics[CHARGING_STATE].value = 1
@@ -155,8 +171,9 @@ async def test_battery_charging(hass, utcnow):
async def test_battery_low(hass, utcnow):
"""Test reading the state of a HomeKit battery's low state."""
- sensor = create_battery_level_sensor()
- helper = await setup_test_component(hass, [sensor], suffix="battery")
+ helper = await setup_test_component(
+ hass, create_battery_level_sensor, suffix="battery"
+ )
helper.characteristics[LO_BATT].value = 0
helper.characteristics[BATTERY_LEVEL].value = 1
diff --git a/tests/components/homekit_controller/test_storage.py b/tests/components/homekit_controller/test_storage.py
index 39b0d9d8250..4f0dabb9bc8 100644
--- a/tests/components/homekit_controller/test_storage.py
+++ b/tests/components/homekit_controller/test_storage.py
@@ -1,11 +1,13 @@
"""Basic checks for entity map storage."""
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
from homeassistant import config_entries
from homeassistant.components.homekit_controller import async_remove_entry
from homeassistant.components.homekit_controller.const import ENTITY_MAP
from tests.common import flush_store
from tests.components.homekit_controller.common import (
- FakeService,
setup_platform,
setup_test_component,
)
@@ -57,18 +59,16 @@ async def test_storage_is_removed_idempotent(hass):
assert hkid not in entity_map.storage_data
-def create_lightbulb_service():
+def create_lightbulb_service(accessory):
"""Define lightbulb characteristics."""
- service = FakeService("public.hap.service.lightbulb")
- on_char = service.add_characteristic("on")
+ service = accessory.add_service(ServicesTypes.LIGHTBULB)
+ on_char = service.add_char(CharacteristicsTypes.ON)
on_char.value = 0
- return service
async def test_storage_is_updated_on_add(hass, hass_storage, utcnow):
"""Test entity map storage is cleaned up on adding an accessory."""
- bulb = create_lightbulb_service()
- await setup_test_component(hass, [bulb])
+ await setup_test_component(hass, create_lightbulb_service)
entity_map = hass.data[ENTITY_MAP]
hkid = "00:00:00:00:00:00"
@@ -83,8 +83,7 @@ async def test_storage_is_updated_on_add(hass, hass_storage, utcnow):
async def test_storage_is_removed_on_config_entry_removal(hass, utcnow):
"""Test entity map storage is cleaned up on config entry removal."""
- bulb = create_lightbulb_service()
- await setup_test_component(hass, [bulb])
+ await setup_test_component(hass, create_lightbulb_service)
hkid = "00:00:00:00:00:00"
diff --git a/tests/components/homekit_controller/test_switch.py b/tests/components/homekit_controller/test_switch.py
index 82dae3f4a6e..eb10d42e208 100644
--- a/tests/components/homekit_controller/test_switch.py
+++ b/tests/components/homekit_controller/test_switch.py
@@ -1,12 +1,25 @@
"""Basic checks for HomeKitSwitch."""
+
+from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.services import ServicesTypes
+
from tests.components.homekit_controller.common import setup_test_component
+def create_switch_service(accessory):
+ """Define outlet characteristics."""
+ service = accessory.add_service(ServicesTypes.OUTLET)
+
+ on_char = service.add_char(CharacteristicsTypes.ON)
+ on_char.value = False
+
+ outlet_in_use = service.add_char(CharacteristicsTypes.OUTLET_IN_USE)
+ outlet_in_use.value = False
+
+
async def test_switch_change_outlet_state(hass, utcnow):
"""Test that we can turn a HomeKit outlet on and off again."""
- from homekit.model.services import OutletService
-
- helper = await setup_test_component(hass, [OutletService()])
+ helper = await setup_test_component(hass, create_switch_service)
await hass.services.async_call(
"switch", "turn_on", {"entity_id": "switch.testdevice"}, blocking=True
@@ -21,9 +34,7 @@ async def test_switch_change_outlet_state(hass, utcnow):
async def test_switch_read_outlet_state(hass, utcnow):
"""Test that we can read the state of a HomeKit outlet accessory."""
- from homekit.model.services import OutletService
-
- helper = await setup_test_component(hass, [OutletService()])
+ helper = await setup_test_component(hass, create_switch_service)
# Initial state is that the switch is off and the outlet isn't in use
switch_1 = await helper.poll_and_get_state()
diff --git a/tests/components/homematicip_cloud/conftest.py b/tests/components/homematicip_cloud/conftest.py
index 502e9d1b73e..927690d881f 100644
--- a/tests/components/homematicip_cloud/conftest.py
+++ b/tests/components/homematicip_cloud/conftest.py
@@ -1,5 +1,5 @@
"""Initializer helpers for HomematicIP fake server."""
-from asynctest import CoroutineMock, MagicMock, Mock
+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
@@ -106,9 +106,10 @@ async def mock_hap_with_service_fixture(
@pytest.fixture(name="simple_mock_home")
-def simple_mock_home_fixture() -> AsyncHome:
- """Return a simple AsyncHome Mock."""
- return Mock(
+def simple_mock_home_fixture():
+ """Return a simple mocked connection."""
+
+ mock_home = Mock(
spec=AsyncHome,
name="Demo",
devices=[],
@@ -120,6 +121,27 @@ def simple_mock_home_fixture() -> AsyncHome:
connected=True,
)
+ with patch(
+ "homeassistant.components.homematicip_cloud.hap.AsyncHome",
+ autospec=True,
+ return_value=mock_home,
+ ):
+ yield
+
+
+@pytest.fixture(name="mock_connection_init")
+def mock_connection_init_fixture():
+ """Return a simple mocked connection."""
+
+ with patch(
+ "homeassistant.components.homematicip_cloud.hap.AsyncHome.init",
+ return_value=None,
+ ), patch(
+ "homeassistant.components.homematicip_cloud.hap.AsyncAuth.init",
+ return_value=None,
+ ):
+ yield
+
@pytest.fixture(name="simple_mock_auth")
def simple_mock_auth_fixture() -> AsyncAuth:
diff --git a/tests/components/homematicip_cloud/test_alarm_control_panel.py b/tests/components/homematicip_cloud/test_alarm_control_panel.py
index 23e5beb40eb..92782f2cbb2 100644
--- a/tests/components/homematicip_cloud/test_alarm_control_panel.py
+++ b/tests/components/homematicip_cloud/test_alarm_control_panel.py
@@ -31,9 +31,7 @@ async def _async_manipulate_security_zones(
internal_zone = home.search_group_by_id(internal_zone_id)
internal_zone.active = internal_active
- home.from_json(json)
- home._get_functionalHomes(json)
- home._load_functionalChannels()
+ home.update_home_only(json)
home.fire_update_event(json)
await hass.async_block_till_done()
diff --git a/tests/components/homematicip_cloud/test_config_flow.py b/tests/components/homematicip_cloud/test_config_flow.py
index 01e820e7565..6436433a147 100644
--- a/tests/components/homematicip_cloud/test_config_flow.py
+++ b/tests/components/homematicip_cloud/test_config_flow.py
@@ -16,12 +16,15 @@ DEFAULT_CONFIG = {HMIPC_HAPID: "ABC123", HMIPC_PIN: "123", HMIPC_NAME: "hmip"}
IMPORT_CONFIG = {HMIPC_HAPID: "ABC123", HMIPC_AUTHTOKEN: "123", HMIPC_NAME: "hmip"}
-async def test_flow_works(hass):
+async def test_flow_works(hass, simple_mock_home):
"""Test config flow."""
with patch(
"homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_checkbutton",
return_value=False,
+ ), patch(
+ "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.get_auth",
+ return_value=True,
):
result = await hass.config_entries.flow.async_init(
HMIPC_DOMAIN, context={"source": "user"}, data=DEFAULT_CONFIG
@@ -137,7 +140,7 @@ async def test_init_already_configured(hass):
assert result["reason"] == "already_configured"
-async def test_import_config(hass):
+async def test_import_config(hass, simple_mock_home):
"""Test importing a host with an existing config file."""
with patch(
"homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_checkbutton",
diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py
index c678bee5e32..3cb45182399 100644
--- a/tests/components/homematicip_cloud/test_device.py
+++ b/tests/components/homematicip_cloud/test_device.py
@@ -196,7 +196,7 @@ async def test_hap_with_name(hass, mock_connection, hmip_config_entry):
entity_name = f"{home_name} Treppe"
device_model = "HmIP-BSL"
- hmip_config_entry.data["name"] = home_name
+ hmip_config_entry.data = {**hmip_config_entry.data, "name": home_name}
mock_hap = await HomeFactory(
hass, mock_connection, hmip_config_entry
).async_get_mock_hap(test_devices=["Treppe"])
diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py
index 1dd5b2fc789..e6e143973f3 100644
--- a/tests/components/homematicip_cloud/test_hap.py
+++ b/tests/components/homematicip_cloud/test_hap.py
@@ -125,14 +125,11 @@ async def test_hap_create(hass, hmip_config_entry, simple_mock_home):
hass.config.components.add(HMIPC_DOMAIN)
hap = HomematicipHAP(hass, hmip_config_entry)
assert hap
- with patch(
- "homeassistant.components.homematicip_cloud.hap.AsyncHome",
- return_value=simple_mock_home,
- ), patch.object(hap, "async_connect"):
+ with patch.object(hap, "async_connect"):
assert await hap.async_setup()
-async def test_hap_create_exception(hass, hmip_config_entry):
+async def test_hap_create_exception(hass, hmip_config_entry, mock_connection_init):
"""Mock AsyncHome to execute get_hap."""
hass.config.components.add(HMIPC_DOMAIN)
diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py
index ef7f5fa24ae..f97e7114b94 100644
--- a/tests/components/homematicip_cloud/test_init.py
+++ b/tests/components/homematicip_cloud/test_init.py
@@ -24,7 +24,9 @@ from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
-async def test_config_with_accesspoint_passed_to_config_entry(hass):
+async def test_config_with_accesspoint_passed_to_config_entry(
+ hass, mock_connection, simple_mock_home
+):
"""Test that config for a accesspoint are loaded via config entry."""
entry_config = {
@@ -51,7 +53,9 @@ async def test_config_with_accesspoint_passed_to_config_entry(hass):
assert isinstance(hass.data[HMIPC_DOMAIN]["ABC123"], HomematicipHAP)
-async def test_config_already_registered_not_passed_to_config_entry(hass):
+async def test_config_already_registered_not_passed_to_config_entry(
+ hass, simple_mock_home
+):
"""Test that an already registered accesspoint does not get imported."""
mock_config = {HMIPC_AUTHTOKEN: "123", HMIPC_HAPID: "ABC123", HMIPC_NAME: "name"}
@@ -87,7 +91,9 @@ async def test_config_already_registered_not_passed_to_config_entry(hass):
assert config_entries[0].unique_id == "ABC123"
-async def test_load_entry_fails_due_to_connection_error(hass, hmip_config_entry):
+async def test_load_entry_fails_due_to_connection_error(
+ hass, hmip_config_entry, mock_connection_init
+):
"""Test load entry fails due to connection error."""
hmip_config_entry.add_to_hass(hass)
@@ -101,7 +107,9 @@ async def test_load_entry_fails_due_to_connection_error(hass, hmip_config_entry)
assert hmip_config_entry.state == ENTRY_STATE_SETUP_RETRY
-async def test_load_entry_fails_due_to_generic_exception(hass, hmip_config_entry):
+async def test_load_entry_fails_due_to_generic_exception(
+ hass, hmip_config_entry, simple_mock_home
+):
"""Test load entry fails due to generic exception."""
hmip_config_entry.add_to_hass(hass)
diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py
index 2ca36e228cc..c55a4682216 100644
--- a/tests/components/homematicip_cloud/test_sensor.py
+++ b/tests/components/homematicip_cloud/test_sensor.py
@@ -22,7 +22,13 @@ from homeassistant.components.homematicip_cloud.sensor import (
ATTR_WIND_DIRECTION_VARIATION,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
-from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, POWER_WATT, TEMP_CELSIUS
+from homeassistant.const import (
+ ATTR_UNIT_OF_MEASUREMENT,
+ POWER_WATT,
+ SPEED_KILOMETERS_PER_HOUR,
+ TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
+)
from homeassistant.setup import async_setup_component
from .helper import async_manipulate_test_data, get_and_check_entity_basics
@@ -50,7 +56,7 @@ async def test_hmip_accesspoint_status(hass, default_mock_hap_factory):
)
assert hmip_device
assert ha_state.state == "8.0"
- assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "%"
+ assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UNIT_PERCENTAGE
await async_manipulate_test_data(hass, hmip_device, "dutyCycle", 17.3)
@@ -72,7 +78,7 @@ async def test_hmip_heating_thermostat(hass, default_mock_hap_factory):
)
assert ha_state.state == "0"
- assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "%"
+ assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UNIT_PERCENTAGE
await async_manipulate_test_data(hass, hmip_device, "valvePosition", 0.37)
ha_state = hass.states.get(entity_id)
assert ha_state.state == "37"
@@ -106,7 +112,7 @@ async def test_hmip_humidity_sensor(hass, default_mock_hap_factory):
)
assert ha_state.state == "40"
- assert ha_state.attributes["unit_of_measurement"] == "%"
+ assert ha_state.attributes["unit_of_measurement"] == UNIT_PERCENTAGE
await async_manipulate_test_data(hass, hmip_device, "humidity", 45)
ha_state = hass.states.get(entity_id)
assert ha_state.state == "45"
@@ -284,7 +290,7 @@ async def test_hmip_windspeed_sensor(hass, default_mock_hap_factory):
)
assert ha_state.state == "2.6"
- assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "km/h"
+ assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == SPEED_KILOMETERS_PER_HOUR
await async_manipulate_test_data(hass, hmip_device, "windSpeed", 9.4)
ha_state = hass.states.get(entity_id)
assert ha_state.state == "9.4"
diff --git a/tests/components/icloud/test_config_flow.py b/tests/components/icloud/test_config_flow.py
index 6091d1cf1da..4bce35d0a63 100644
--- a/tests/components/icloud/test_config_flow.py
+++ b/tests/components/icloud/test_config_flow.py
@@ -12,8 +12,10 @@ from homeassistant.components.icloud.config_flow import (
from homeassistant.components.icloud.const import (
CONF_GPS_ACCURACY_THRESHOLD,
CONF_MAX_INTERVAL,
+ CONF_WITH_FAMILY,
DEFAULT_GPS_ACCURACY_THRESHOLD,
DEFAULT_MAX_INTERVAL,
+ DEFAULT_WITH_FAMILY,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
@@ -25,6 +27,7 @@ from tests.common import MockConfigEntry
USERNAME = "username@me.com"
USERNAME_2 = "second_username@icloud.com"
PASSWORD = "password"
+WITH_FAMILY = True
MAX_INTERVAL = 15
GPS_ACCURACY_THRESHOLD = 250
@@ -46,8 +49,8 @@ def mock_controller_service():
yield service_mock
-@pytest.fixture(name="service_with_cookie")
-def mock_controller_service_with_cookie():
+@pytest.fixture(name="service_authenticated")
+def mock_controller_service_authenticated():
"""Mock a successful service while already authenticate."""
with patch(
"homeassistant.components.icloud.config_flow.PyiCloudService"
@@ -59,6 +62,20 @@ def mock_controller_service_with_cookie():
yield service_mock
+@pytest.fixture(name="service_authenticated_no_device")
+def mock_controller_service_authenticated_no_device():
+ """Mock a successful service while already authenticate, but without device."""
+ with patch(
+ "homeassistant.components.icloud.config_flow.PyiCloudService"
+ ) as service_mock:
+ service_mock.return_value.requires_2sa = False
+ service_mock.return_value.trusted_devices = TRUSTED_DEVICES
+ service_mock.return_value.send_verification_code = Mock(return_value=True)
+ service_mock.return_value.validate_verification_code = Mock(return_value=True)
+ service_mock.return_value.devices = {}
+ yield service_mock
+
+
@pytest.fixture(name="service_send_verification_code_failed")
def mock_controller_service_send_verification_code_failed():
"""Mock a failed service during sending verification code step."""
@@ -92,7 +109,7 @@ async def test_user(hass: HomeAssistantType, service: MagicMock):
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
- # test with all provided
+ # test with required
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
@@ -103,27 +120,32 @@ async def test_user(hass: HomeAssistantType, service: MagicMock):
async def test_user_with_cookie(
- hass: HomeAssistantType, service_with_cookie: MagicMock
+ hass: HomeAssistantType, service_authenticated: MagicMock
):
"""Test user config with presence of a cookie."""
# test with all provided
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
- data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD},
+ data={
+ CONF_USERNAME: USERNAME,
+ CONF_PASSWORD: PASSWORD,
+ CONF_WITH_FAMILY: WITH_FAMILY,
+ },
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["result"].unique_id == USERNAME
assert result["title"] == USERNAME
assert result["data"][CONF_USERNAME] == USERNAME
assert result["data"][CONF_PASSWORD] == PASSWORD
+ assert result["data"][CONF_WITH_FAMILY] == WITH_FAMILY
assert result["data"][CONF_MAX_INTERVAL] == DEFAULT_MAX_INTERVAL
assert result["data"][CONF_GPS_ACCURACY_THRESHOLD] == DEFAULT_GPS_ACCURACY_THRESHOLD
async def test_import(hass: HomeAssistantType, service: MagicMock):
"""Test import step."""
- # import with username and password
+ # import with required
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
@@ -139,6 +161,7 @@ async def test_import(hass: HomeAssistantType, service: MagicMock):
data={
CONF_USERNAME: USERNAME_2,
CONF_PASSWORD: PASSWORD,
+ CONF_WITH_FAMILY: WITH_FAMILY,
CONF_MAX_INTERVAL: MAX_INTERVAL,
CONF_GPS_ACCURACY_THRESHOLD: GPS_ACCURACY_THRESHOLD,
},
@@ -148,10 +171,10 @@ async def test_import(hass: HomeAssistantType, service: MagicMock):
async def test_import_with_cookie(
- hass: HomeAssistantType, service_with_cookie: MagicMock
+ hass: HomeAssistantType, service_authenticated: MagicMock
):
"""Test import step with presence of a cookie."""
- # import with username and password
+ # import with required
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
@@ -162,6 +185,7 @@ async def test_import_with_cookie(
assert result["title"] == USERNAME
assert result["data"][CONF_USERNAME] == USERNAME
assert result["data"][CONF_PASSWORD] == PASSWORD
+ assert result["data"][CONF_WITH_FAMILY] == DEFAULT_WITH_FAMILY
assert result["data"][CONF_MAX_INTERVAL] == DEFAULT_MAX_INTERVAL
assert result["data"][CONF_GPS_ACCURACY_THRESHOLD] == DEFAULT_GPS_ACCURACY_THRESHOLD
@@ -172,6 +196,7 @@ async def test_import_with_cookie(
data={
CONF_USERNAME: USERNAME_2,
CONF_PASSWORD: PASSWORD,
+ CONF_WITH_FAMILY: WITH_FAMILY,
CONF_MAX_INTERVAL: MAX_INTERVAL,
CONF_GPS_ACCURACY_THRESHOLD: GPS_ACCURACY_THRESHOLD,
},
@@ -181,12 +206,13 @@ async def test_import_with_cookie(
assert result["title"] == USERNAME_2
assert result["data"][CONF_USERNAME] == USERNAME_2
assert result["data"][CONF_PASSWORD] == PASSWORD
+ assert result["data"][CONF_WITH_FAMILY] == WITH_FAMILY
assert result["data"][CONF_MAX_INTERVAL] == MAX_INTERVAL
assert result["data"][CONF_GPS_ACCURACY_THRESHOLD] == GPS_ACCURACY_THRESHOLD
async def test_two_accounts_setup(
- hass: HomeAssistantType, service_with_cookie: MagicMock
+ hass: HomeAssistantType, service_authenticated: MagicMock
):
"""Test to setup two accounts."""
MockConfigEntry(
@@ -195,7 +221,7 @@ async def test_two_accounts_setup(
unique_id=USERNAME,
).add_to_hass(hass)
- # import with username and password
+ # import with required
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
@@ -206,11 +232,12 @@ async def test_two_accounts_setup(
assert result["title"] == USERNAME_2
assert result["data"][CONF_USERNAME] == USERNAME_2
assert result["data"][CONF_PASSWORD] == PASSWORD
+ assert result["data"][CONF_WITH_FAMILY] == DEFAULT_WITH_FAMILY
assert result["data"][CONF_MAX_INTERVAL] == DEFAULT_MAX_INTERVAL
assert result["data"][CONF_GPS_ACCURACY_THRESHOLD] == DEFAULT_GPS_ACCURACY_THRESHOLD
-async def test_abort_if_already_setup(hass: HomeAssistantType):
+async def test_already_setup(hass: HomeAssistantType):
"""Test we abort if the account is already setup."""
MockConfigEntry(
domain=DOMAIN,
@@ -240,7 +267,7 @@ async def test_abort_if_already_setup(hass: HomeAssistantType):
async def test_login_failed(hass: HomeAssistantType):
"""Test when we have errors during login."""
with patch(
- "pyicloud.base.PyiCloudService.authenticate",
+ "homeassistant.components.icloud.config_flow.PyiCloudService.authenticate",
side_effect=PyiCloudFailedLoginException(),
):
result = await hass.config_entries.flow.async_init(
@@ -252,6 +279,19 @@ async def test_login_failed(hass: HomeAssistantType):
assert result["errors"] == {CONF_USERNAME: "login"}
+async def test_no_device(
+ hass: HomeAssistantType, service_authenticated_no_device: MagicMock
+):
+ """Test when we have no devices."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "no_device"
+
+
async def test_trusted_device(hass: HomeAssistantType, service: MagicMock):
"""Test trusted_device step."""
result = await hass.config_entries.flow.async_init(
@@ -334,6 +374,7 @@ async def test_verification_code_success(hass: HomeAssistantType, service: Magic
assert result["title"] == USERNAME
assert result["data"][CONF_USERNAME] == USERNAME
assert result["data"][CONF_PASSWORD] == PASSWORD
+ assert result["data"][CONF_WITH_FAMILY] == DEFAULT_WITH_FAMILY
assert result["data"][CONF_MAX_INTERVAL] == DEFAULT_MAX_INTERVAL
assert result["data"][CONF_GPS_ACCURACY_THRESHOLD] == DEFAULT_GPS_ACCURACY_THRESHOLD
diff --git a/tests/components/ifttt/test_init.py b/tests/components/ifttt/test_init.py
index 74d12ba44f4..ab5aa7ea1ad 100644
--- a/tests/components/ifttt/test_init.py
+++ b/tests/components/ifttt/test_init.py
@@ -33,3 +33,11 @@ async def test_config_flow_registers_webhook(hass, aiohttp_client):
assert len(ifttt_events) == 1
assert ifttt_events[0].data["webhook_id"] == webhook_id
assert ifttt_events[0].data["hello"] == "ifttt"
+
+ # Invalid JSON
+ await client.post("/api/webhook/{}".format(webhook_id), data="not a dict")
+ assert len(ifttt_events) == 1
+
+ # Not a dict
+ await client.post("/api/webhook/{}".format(webhook_id), json="not a dict")
+ assert len(ifttt_events) == 1
diff --git a/tests/components/influxdb/test_init.py b/tests/components/influxdb/test_init.py
index 1dd2681b7f2..2bb9faad8e5 100644
--- a/tests/components/influxdb/test_init.py
+++ b/tests/components/influxdb/test_init.py
@@ -4,7 +4,13 @@ import unittest
from unittest import mock
import homeassistant.components.influxdb as influxdb
-from homeassistant.const import EVENT_STATE_CHANGED, STATE_OFF, STATE_ON, STATE_STANDBY
+from homeassistant.const import (
+ EVENT_STATE_CHANGED,
+ STATE_OFF,
+ STATE_ON,
+ STATE_STANDBY,
+ UNIT_PERCENTAGE,
+)
from homeassistant.setup import setup_component
from tests.common import get_test_home_assistant
@@ -102,7 +108,7 @@ class TestInfluxDB(unittest.TestCase):
"unit_of_measurement": "foobars",
"longitude": "1.1",
"latitude": "2.2",
- "battery_level": "99%",
+ "battery_level": f"99{UNIT_PERCENTAGE}",
"temperature": "20c",
"last_seen": "Last seen 23 minutes ago",
"updated_at": datetime.datetime(2017, 1, 1, 0, 0),
@@ -124,7 +130,7 @@ class TestInfluxDB(unittest.TestCase):
"fields": {
"longitude": 1.1,
"latitude": 2.2,
- "battery_level_str": "99%",
+ "battery_level_str": f"99{UNIT_PERCENTAGE}",
"battery_level": 99.0,
"temperature_str": "20c",
"temperature": 20.0,
diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py
index fa67bb2f8bf..afee32702c4 100644
--- a/tests/components/input_datetime/test_init.py
+++ b/tests/components/input_datetime/test_init.py
@@ -268,12 +268,15 @@ async def test_restore_state(hass):
State("input_datetime.test_date", "2017-09-07"),
State("input_datetime.test_datetime", "2017-09-07 19:46:00"),
State("input_datetime.test_bogus_data", "this is not a date"),
+ State("input_datetime.test_was_time", "19:46:00"),
+ State("input_datetime.test_was_date", "2017-09-07"),
),
)
hass.state = CoreState.starting
initial = datetime.datetime(2017, 1, 1, 23, 42)
+ default = datetime.datetime(1970, 1, 1, 0, 0)
await async_setup_component(
hass,
@@ -288,6 +291,8 @@ async def test_restore_state(hass):
"has_date": True,
"initial": str(initial),
},
+ "test_was_time": {"has_time": False, "has_date": True},
+ "test_was_date": {"has_time": True, "has_date": False},
}
},
)
@@ -305,6 +310,12 @@ async def test_restore_state(hass):
state_bogus = hass.states.get("input_datetime.test_bogus_data")
assert state_bogus.state == str(initial)
+ state_was_time = hass.states.get("input_datetime.test_was_time")
+ assert state_was_time.state == str(default.date())
+
+ state_was_date = hass.states.get("input_datetime.test_was_date")
+ assert state_was_date.state == str(default.time())
+
async def test_default_value(hass):
"""Test default value if none has been set via initial or restore state."""
diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py
index 8331e1374c8..28b9d27d23f 100644
--- a/tests/components/input_number/test_init.py
+++ b/tests/components/input_number/test_init.py
@@ -3,6 +3,7 @@
from unittest.mock import patch
import pytest
+import voluptuous as vol
from homeassistant.components.input_number import (
ATTR_VALUE,
@@ -21,7 +22,6 @@ from homeassistant.const import (
from homeassistant.core import Context, CoreState, State
from homeassistant.exceptions import Unauthorized
from homeassistant.helpers import entity_registry
-from homeassistant.loader import bind_hass
from homeassistant.setup import async_setup_component
from tests.common import mock_restore_cache
@@ -63,38 +63,36 @@ def storage_setup(hass, hass_storage):
return _storage
-@bind_hass
-def set_value(hass, entity_id, value):
+async def set_value(hass, entity_id, value):
"""Set input_number to value.
This is a legacy helper method. Do not use it for new tests.
"""
- hass.async_create_task(
- hass.services.async_call(
- DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}
- )
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_VALUE,
+ {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value},
+ blocking=True,
)
-@bind_hass
-def increment(hass, entity_id):
+async def increment(hass, entity_id):
"""Increment value of entity.
This is a legacy helper method. Do not use it for new tests.
"""
- hass.async_create_task(
- hass.services.async_call(DOMAIN, SERVICE_INCREMENT, {ATTR_ENTITY_ID: entity_id})
+ await hass.services.async_call(
+ DOMAIN, SERVICE_INCREMENT, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
-@bind_hass
-def decrement(hass, entity_id):
+async def decrement(hass, entity_id):
"""Decrement value of entity.
This is a legacy helper method. Do not use it for new tests.
"""
- hass.async_create_task(
- hass.services.async_call(DOMAIN, SERVICE_DECREMENT, {ATTR_ENTITY_ID: entity_id})
+ await hass.services.async_call(
+ DOMAIN, SERVICE_DECREMENT, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
@@ -110,7 +108,7 @@ async def test_config(hass):
assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg})
-async def test_set_value(hass):
+async def test_set_value(hass, caplog):
"""Test set_value method."""
assert await async_setup_component(
hass, DOMAIN, {DOMAIN: {"test_1": {"initial": 50, "min": 0, "max": 100}}}
@@ -120,20 +118,22 @@ async def test_set_value(hass):
state = hass.states.get(entity_id)
assert 50 == float(state.state)
- set_value(hass, entity_id, "30.4")
- await hass.async_block_till_done()
+ await set_value(hass, entity_id, "30.4")
state = hass.states.get(entity_id)
assert 30.4 == float(state.state)
- set_value(hass, entity_id, "70")
- await hass.async_block_till_done()
+ await set_value(hass, entity_id, "70")
state = hass.states.get(entity_id)
assert 70 == float(state.state)
- set_value(hass, entity_id, "110")
- await hass.async_block_till_done()
+ with pytest.raises(vol.Invalid) as excinfo:
+ await set_value(hass, entity_id, "110")
+
+ assert "Invalid value for input_number.test_1: 110.0 (range 0.0 - 100.0)" in str(
+ excinfo.value
+ )
state = hass.states.get(entity_id)
assert 70 == float(state.state)
@@ -149,13 +149,13 @@ async def test_increment(hass):
state = hass.states.get(entity_id)
assert 50 == float(state.state)
- increment(hass, entity_id)
+ await increment(hass, entity_id)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert 51 == float(state.state)
- increment(hass, entity_id)
+ await increment(hass, entity_id)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
@@ -172,13 +172,13 @@ async def test_decrement(hass):
state = hass.states.get(entity_id)
assert 50 == float(state.state)
- decrement(hass, entity_id)
+ await decrement(hass, entity_id)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert 49 == float(state.state)
- decrement(hass, entity_id)
+ await decrement(hass, entity_id)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py
index c65ca720235..b598d7ddbc2 100644
--- a/tests/components/integration/test_sensor.py
+++ b/tests/components/integration/test_sensor.py
@@ -2,6 +2,7 @@
from datetime import timedelta
from unittest.mock import patch
+from homeassistant.const import TIME_SECONDS
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
@@ -181,7 +182,7 @@ async def test_suffix(hass):
"source": "sensor.bytes_per_second",
"round": 2,
"unit_prefix": "k",
- "unit_time": "s",
+ "unit_time": TIME_SECONDS,
}
}
diff --git a/tests/components/konnected/test_init.py b/tests/components/konnected/test_init.py
index 907f83cd981..e410aa9d60a 100644
--- a/tests/components/konnected/test_init.py
+++ b/tests/components/konnected/test_init.py
@@ -582,6 +582,10 @@ async def test_state_updates(hass, aiohttp_client, mock_panel):
)
entry.add_to_hass(hass)
+ # Add empty data field to ensure we process it correctly (possible if entry is ignored)
+ entry = MockConfigEntry(domain="konnected", title="Konnected Alarm Panel", data={},)
+ entry.add_to_hass(hass)
+
assert (
await async_setup_component(
hass,
diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py
index 3ac8171ce7d..6cddfc15744 100644
--- a/tests/components/light/test_device_action.py
+++ b/tests/components/light/test_device_action.py
@@ -9,6 +9,7 @@ from homeassistant.setup import async_setup_component
from tests.common import (
MockConfigEntry,
+ async_get_device_automation_capabilities,
async_get_device_automations,
async_mock_service,
mock_device_registry,
@@ -85,6 +86,66 @@ async def test_get_actions(hass, device_reg, entity_reg):
assert actions == expected_actions
+async def test_get_action_capabilities(hass, device_reg, entity_reg):
+ """Test we get the expected capabilities from a light action."""
+ 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,
+ )
+
+ actions = await async_get_device_automations(hass, "action", device_entry.id)
+ assert len(actions) == 3
+ for action in actions:
+ capabilities = await async_get_device_automation_capabilities(
+ hass, "action", action
+ )
+ assert capabilities == {"extra_fields": []}
+
+
+async def test_get_action_capabilities_brightness(hass, device_reg, entity_reg):
+ """Test we get the expected capabilities from a light action."""
+ 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,
+ supported_features=SUPPORT_BRIGHTNESS,
+ )
+
+ expected_capabilities = {
+ "extra_fields": [
+ {
+ "name": "brightness_pct",
+ "optional": True,
+ "type": "integer",
+ "valueMax": 100,
+ "valueMin": 0,
+ }
+ ]
+ }
+ actions = await async_get_device_automations(hass, "action", device_entry.id)
+ assert len(actions) == 5
+ for action in actions:
+ capabilities = await async_get_device_automation_capabilities(
+ hass, "action", action
+ )
+ if action["type"] == "turn_on":
+ assert capabilities == expected_capabilities
+ else:
+ assert capabilities == {"extra_fields": []}
+
+
async def test_action(hass, calls):
"""Test for turn_on and turn_off actions."""
platform = getattr(hass.components, f"test.{DOMAIN}")
@@ -100,7 +161,7 @@ async def test_action(hass, calls):
{
automation.DOMAIN: [
{
- "trigger": {"platform": "event", "event_type": "test_event1"},
+ "trigger": {"platform": "event", "event_type": "test_off"},
"action": {
"domain": DOMAIN,
"device_id": "",
@@ -109,7 +170,7 @@ async def test_action(hass, calls):
},
},
{
- "trigger": {"platform": "event", "event_type": "test_event2"},
+ "trigger": {"platform": "event", "event_type": "test_on"},
"action": {
"domain": DOMAIN,
"device_id": "",
@@ -118,7 +179,7 @@ async def test_action(hass, calls):
},
},
{
- "trigger": {"platform": "event", "event_type": "test_event3"},
+ "trigger": {"platform": "event", "event_type": "test_toggle"},
"action": {
"domain": DOMAIN,
"device_id": "",
@@ -150,6 +211,16 @@ async def test_action(hass, calls):
"type": "brightness_decrease",
},
},
+ {
+ "trigger": {"platform": "event", "event_type": "test_brightness"},
+ "action": {
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": ent1.entity_id,
+ "type": "turn_on",
+ "brightness_pct": 75,
+ },
+ },
]
},
)
@@ -157,27 +228,27 @@ async def test_action(hass, calls):
assert hass.states.get(ent1.entity_id).state == STATE_ON
assert len(calls) == 0
- hass.bus.async_fire("test_event1")
+ hass.bus.async_fire("test_off")
await hass.async_block_till_done()
assert hass.states.get(ent1.entity_id).state == STATE_OFF
- hass.bus.async_fire("test_event1")
+ hass.bus.async_fire("test_off")
await hass.async_block_till_done()
assert hass.states.get(ent1.entity_id).state == STATE_OFF
- hass.bus.async_fire("test_event2")
+ hass.bus.async_fire("test_on")
await hass.async_block_till_done()
assert hass.states.get(ent1.entity_id).state == STATE_ON
- hass.bus.async_fire("test_event2")
+ hass.bus.async_fire("test_on")
await hass.async_block_till_done()
assert hass.states.get(ent1.entity_id).state == STATE_ON
- hass.bus.async_fire("test_event3")
+ hass.bus.async_fire("test_toggle")
await hass.async_block_till_done()
assert hass.states.get(ent1.entity_id).state == STATE_OFF
- hass.bus.async_fire("test_event3")
+ hass.bus.async_fire("test_toggle")
await hass.async_block_till_done()
assert hass.states.get(ent1.entity_id).state == STATE_ON
@@ -196,3 +267,17 @@ async def test_action(hass, calls):
assert len(turn_on_calls) == 2
assert turn_on_calls[1].data["entity_id"] == ent1.entity_id
assert turn_on_calls[1].data["brightness_step_pct"] == -10
+
+ hass.bus.async_fire("test_brightness")
+ await hass.async_block_till_done()
+
+ assert len(turn_on_calls) == 3
+ assert turn_on_calls[2].data["entity_id"] == ent1.entity_id
+ assert turn_on_calls[2].data["brightness_pct"] == 75
+
+ hass.bus.async_fire("test_on")
+ await hass.async_block_till_done()
+
+ assert len(turn_on_calls) == 4
+ assert turn_on_calls[3].data["entity_id"] == ent1.entity_id
+ assert "brightness_pct" not in turn_on_calls[3].data
diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py
index 750ad17b523..98653dc5a6c 100644
--- a/tests/components/logbook/test_init.py
+++ b/tests/components/logbook/test_init.py
@@ -1,9 +1,11 @@
"""The tests for the logbook component."""
# pylint: disable=protected-access,invalid-name
from datetime import datetime, timedelta
+from functools import partial
import logging
import unittest
+from asynctest import patch
import pytest
import voluptuous as vol
@@ -34,6 +36,7 @@ from homeassistant.setup import async_setup_component, setup_component
import homeassistant.util.dt as dt_util
from tests.common import get_test_home_assistant, init_recorder_component
+from tests.components.recorder.common import trigger_db_commit
_LOGGER = logging.getLogger(__name__)
@@ -169,7 +172,7 @@ class TestComponentLogbook(unittest.TestCase):
events = [
e
for e in (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB)
- if logbook._keep_event(e, entities_filter)
+ if logbook._keep_event(self.hass, e, entities_filter)
]
entries = list(logbook.humanify(self.hass, events))
@@ -196,7 +199,7 @@ class TestComponentLogbook(unittest.TestCase):
events = [
e
for e in (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB)
- if logbook._keep_event(e, entities_filter)
+ if logbook._keep_event(self.hass, e, entities_filter)
]
entries = list(logbook.humanify(self.hass, events))
@@ -224,7 +227,7 @@ class TestComponentLogbook(unittest.TestCase):
events = [
e
for e in (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB)
- if logbook._keep_event(e, entities_filter)
+ if logbook._keep_event(self.hass, e, entities_filter)
]
entries = list(logbook.humanify(self.hass, events))
@@ -258,7 +261,7 @@ class TestComponentLogbook(unittest.TestCase):
events = [
e
for e in (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB)
- if logbook._keep_event(e, entities_filter)
+ if logbook._keep_event(self.hass, e, entities_filter)
]
entries = list(logbook.humanify(self.hass, events))
@@ -300,7 +303,7 @@ class TestComponentLogbook(unittest.TestCase):
eventA,
eventB,
)
- if logbook._keep_event(e, entities_filter)
+ if logbook._keep_event(self.hass, e, entities_filter)
]
entries = list(logbook.humanify(self.hass, events))
@@ -341,7 +344,7 @@ class TestComponentLogbook(unittest.TestCase):
events = [
e
for e in (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB)
- if logbook._keep_event(e, entities_filter)
+ if logbook._keep_event(self.hass, e, entities_filter)
]
entries = list(logbook.humanify(self.hass, events))
@@ -380,7 +383,7 @@ class TestComponentLogbook(unittest.TestCase):
events = [
e
for e in (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB)
- if logbook._keep_event(e, entities_filter)
+ if logbook._keep_event(self.hass, e, entities_filter)
]
entries = list(logbook.humanify(self.hass, events))
@@ -412,7 +415,7 @@ class TestComponentLogbook(unittest.TestCase):
events = [
e
for e in (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB)
- if logbook._keep_event(e, entities_filter)
+ if logbook._keep_event(self.hass, e, entities_filter)
]
entries = list(logbook.humanify(self.hass, events))
@@ -426,6 +429,7 @@ class TestComponentLogbook(unittest.TestCase):
def test_include_events_domain(self):
"""Test if events are filtered if domain is included in config."""
+ assert setup_component(self.hass, "alexa", {})
entity_id = "switch.bla"
entity_id2 = "sensor.blu"
pointA = dt_util.utcnow()
@@ -467,7 +471,7 @@ class TestComponentLogbook(unittest.TestCase):
eventA,
eventB,
)
- if logbook._keep_event(e, entities_filter)
+ if logbook._keep_event(self.hass, e, entities_filter)
]
entries = list(logbook.humanify(self.hass, events))
@@ -521,7 +525,7 @@ class TestComponentLogbook(unittest.TestCase):
eventB1,
eventB2,
)
- if logbook._keep_event(e, entities_filter)
+ if logbook._keep_event(self.hass, e, entities_filter)
]
entries = list(logbook.humanify(self.hass, events))
@@ -553,7 +557,9 @@ class TestComponentLogbook(unittest.TestCase):
entities_filter = logbook._generate_filter_from_config({})
events = [
- e for e in (eventA, eventB) if logbook._keep_event(e, entities_filter)
+ e
+ for e in (eventA, eventB)
+ if logbook._keep_event(self.hass, e, entities_filter)
]
entries = list(logbook.humanify(self.hass, events))
@@ -564,25 +570,48 @@ class TestComponentLogbook(unittest.TestCase):
def test_exclude_attribute_changes(self):
"""Test if events of attribute changes are filtered."""
- entity_id = "switch.bla"
- entity_id2 = "switch.blu"
pointA = dt_util.utcnow()
pointB = pointA + timedelta(minutes=1)
+ pointC = pointB + timedelta(minutes=1)
- eventA = self.create_state_changed_event(pointA, entity_id, 10)
- eventB = self.create_state_changed_event(
- pointA, entity_id2, 20, last_changed=pointA, last_updated=pointB
+ state_off = ha.State("light.kitchen", "off", {}, pointA, pointA).as_dict()
+ state_100 = ha.State(
+ "light.kitchen", "on", {"brightness": 100}, pointB, pointB
+ ).as_dict()
+ state_200 = ha.State(
+ "light.kitchen", "on", {"brightness": 200}, pointB, pointC
+ ).as_dict()
+
+ eventA = ha.Event(
+ EVENT_STATE_CHANGED,
+ {
+ "entity_id": "light.kitchen",
+ "old_state": state_off,
+ "new_state": state_100,
+ },
+ time_fired=pointB,
+ )
+ eventB = ha.Event(
+ EVENT_STATE_CHANGED,
+ {
+ "entity_id": "light.kitchen",
+ "old_state": state_100,
+ "new_state": state_200,
+ },
+ time_fired=pointC,
)
entities_filter = logbook._generate_filter_from_config({})
events = [
- e for e in (eventA, eventB) if logbook._keep_event(e, entities_filter)
+ e
+ for e in (eventA, eventB)
+ if logbook._keep_event(self.hass, e, entities_filter)
]
entries = list(logbook.humanify(self.hass, events))
assert 1 == len(entries)
self.assert_entry(
- entries[0], pointA, "bla", domain="switch", entity_id=entity_id
+ entries[0], pointB, "kitchen", domain="light", entity_id="light.kitchen"
)
def test_home_assistant_start_stop_grouped(self):
@@ -1225,15 +1254,16 @@ class TestComponentLogbook(unittest.TestCase):
last_updated=None,
):
"""Create state changed event."""
- # Logbook only cares about state change events that
- # contain an old state but will not actually act on it.
- state = ha.State(
+ old_state = ha.State(
+ entity_id, "old", attributes, last_changed, last_updated
+ ).as_dict()
+ new_state = ha.State(
entity_id, state, attributes, last_changed, last_updated
).as_dict()
return ha.Event(
EVENT_STATE_CHANGED,
- {"entity_id": entity_id, "old_state": state, "new_state": state},
+ {"entity_id": entity_id, "old_state": old_state, "new_state": new_state},
time_fired=event_time_fired,
)
@@ -1260,6 +1290,7 @@ async def test_logbook_view_period_entity(hass, hass_client):
entity_id_second = "switch.second"
hass.states.async_set(entity_id_second, STATE_OFF)
hass.states.async_set(entity_id_second, STATE_ON)
+ await hass.async_add_job(partial(trigger_db_commit, hass))
await hass.async_block_till_done()
await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done)
@@ -1333,63 +1364,6 @@ async def test_logbook_view_period_entity(hass, hass_client):
assert json[0]["entity_id"] == entity_id_test
-async def test_humanify_alexa_event(hass):
- """Test humanifying Alexa event."""
- hass.states.async_set("light.kitchen", "on", {"friendly_name": "Kitchen Light"})
-
- results = list(
- logbook.humanify(
- hass,
- [
- ha.Event(
- EVENT_ALEXA_SMART_HOME,
- {"request": {"namespace": "Alexa.Discovery", "name": "Discover"}},
- ),
- ha.Event(
- EVENT_ALEXA_SMART_HOME,
- {
- "request": {
- "namespace": "Alexa.PowerController",
- "name": "TurnOn",
- "entity_id": "light.kitchen",
- }
- },
- ),
- ha.Event(
- EVENT_ALEXA_SMART_HOME,
- {
- "request": {
- "namespace": "Alexa.PowerController",
- "name": "TurnOn",
- "entity_id": "light.non_existing",
- }
- },
- ),
- ],
- )
- )
-
- event1, event2, event3 = results
-
- assert event1["name"] == "Amazon Alexa"
- assert event1["message"] == "send command Alexa.Discovery/Discover"
- assert event1["entity_id"] is None
-
- assert event2["name"] == "Amazon Alexa"
- assert (
- event2["message"]
- == "send command Alexa.PowerController/TurnOn for Kitchen Light"
- )
- assert event2["entity_id"] == "light.kitchen"
-
- assert event3["name"] == "Amazon Alexa"
- assert (
- event3["message"]
- == "send command Alexa.PowerController/TurnOn for light.non_existing"
- )
- assert event3["entity_id"] == "light.non_existing"
-
-
async def test_humanify_homekit_changed_event(hass):
"""Test humanifying HomeKit changed event."""
event1, event2 = list(
@@ -1486,34 +1460,31 @@ async def test_humanify_script_started_event(hass):
assert event2["entity_id"] == "script.bye"
-async def test_humanify_same_state(hass):
- """Test humanifying Script Run event."""
- state_50 = ha.State("light.kitchen", "on", {"brightness": 50}).as_dict()
- state_100 = ha.State("light.kitchen", "on", {"brightness": 100}).as_dict()
- state_200 = ha.State("light.kitchen", "on", {"brightness": 200}).as_dict()
-
- events = list(
- logbook.humanify(
- hass,
- [
- ha.Event(
- EVENT_STATE_CHANGED,
- {
- "entity_id": "light.kitchen",
- "old_state": state_50,
- "new_state": state_100,
- },
- ),
- ha.Event(
- EVENT_STATE_CHANGED,
- {
- "entity_id": "light.kitchen",
- "old_state": state_100,
- "new_state": state_200,
- },
- ),
- ],
+async def test_logbook_describe_event(hass, hass_client):
+ """Test teaching logbook about a new event."""
+ await hass.async_add_executor_job(init_recorder_component, hass)
+ assert await async_setup_component(hass, "logbook", {})
+ with patch(
+ "homeassistant.util.dt.utcnow",
+ return_value=dt_util.utcnow() - timedelta(seconds=5),
+ ):
+ hass.bus.async_fire("some_event")
+ await hass.async_block_till_done()
+ await hass.async_add_executor_job(
+ hass.data[recorder.DATA_INSTANCE].block_till_done
)
- )
- assert len(events) == 1
+ def _describe(event):
+ """Describe an event."""
+ return {"name": "Test Name", "message": "tested a message"}
+
+ hass.components.logbook.async_describe_event("test_domain", "some_event", _describe)
+
+ client = await hass_client()
+ response = await client.get("/api/logbook")
+ results = await response.json()
+ assert len(results) == 1
+ event = results[0]
+ assert event["name"] == "Test Name"
+ assert event["message"] == "tested a message"
+ assert event["domain"] == "test_domain"
diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py
new file mode 100644
index 00000000000..775b2760c96
--- /dev/null
+++ b/tests/components/lovelace/test_dashboard.py
@@ -0,0 +1,573 @@
+"""Test the Lovelace initialization."""
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant.components import frontend
+from homeassistant.components.lovelace import const, dashboard
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ assert_setup_component,
+ async_capture_events,
+ get_system_health_info,
+)
+
+
+async def test_lovelace_from_storage(hass, hass_ws_client, hass_storage):
+ """Test we load lovelace config from storage."""
+ assert await async_setup_component(hass, "lovelace", {})
+ assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "storage"}
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({"id": 5, "type": "lovelace/config"})
+ response = await client.receive_json()
+ assert not response["success"]
+ assert response["error"]["code"] == "config_not_found"
+
+ # Store new config
+ events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED)
+
+ await client.send_json(
+ {"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}}
+ )
+ response = await client.receive_json()
+ assert response["success"]
+ assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
+ "config": {"yo": "hello"}
+ }
+ assert len(events) == 1
+
+ # Load new config
+ await client.send_json({"id": 7, "type": "lovelace/config"})
+ response = await client.receive_json()
+ assert response["success"]
+
+ assert response["result"] == {"yo": "hello"}
+
+ # Test with safe mode
+ hass.config.safe_mode = True
+ await client.send_json({"id": 8, "type": "lovelace/config"})
+ response = await client.receive_json()
+ assert not response["success"]
+ assert response["error"]["code"] == "config_not_found"
+
+ await client.send_json(
+ {"id": 9, "type": "lovelace/config/save", "config": {"yo": "hello"}}
+ )
+ response = await client.receive_json()
+ assert not response["success"]
+
+ await client.send_json({"id": 10, "type": "lovelace/config/delete"})
+ response = await client.receive_json()
+ assert not response["success"]
+
+
+async def test_lovelace_from_storage_save_before_load(
+ hass, hass_ws_client, hass_storage
+):
+ """Test we can load lovelace config from storage."""
+ assert await async_setup_component(hass, "lovelace", {})
+ client = await hass_ws_client(hass)
+
+ # Store new config
+ await client.send_json(
+ {"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}}
+ )
+ response = await client.receive_json()
+ assert response["success"]
+ assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
+ "config": {"yo": "hello"}
+ }
+
+
+async def test_lovelace_from_storage_delete(hass, hass_ws_client, hass_storage):
+ """Test we delete lovelace config from storage."""
+ assert await async_setup_component(hass, "lovelace", {})
+ client = await hass_ws_client(hass)
+
+ # Store new config
+ await client.send_json(
+ {"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}}
+ )
+ response = await client.receive_json()
+ assert response["success"]
+ assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
+ "config": {"yo": "hello"}
+ }
+
+ # Delete config
+ await client.send_json({"id": 7, "type": "lovelace/config/delete"})
+ response = await client.receive_json()
+ assert response["success"]
+ assert dashboard.CONFIG_STORAGE_KEY_DEFAULT not in hass_storage
+
+ # Fetch data
+ await client.send_json({"id": 8, "type": "lovelace/config"})
+ response = await client.receive_json()
+ assert not response["success"]
+ assert response["error"]["code"] == "config_not_found"
+
+
+async def test_lovelace_from_yaml(hass, hass_ws_client):
+ """Test we load lovelace config from yaml."""
+ assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}})
+ assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "yaml"}
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({"id": 5, "type": "lovelace/config"})
+ response = await client.receive_json()
+ assert not response["success"]
+
+ assert response["error"]["code"] == "config_not_found"
+
+ # Store new config not allowed
+ await client.send_json(
+ {"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}}
+ )
+ response = await client.receive_json()
+ assert not response["success"]
+
+ # Patch data
+ events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED)
+
+ with patch(
+ "homeassistant.components.lovelace.dashboard.load_yaml",
+ return_value={"hello": "yo"},
+ ):
+ await client.send_json({"id": 7, "type": "lovelace/config"})
+ response = await client.receive_json()
+
+ assert response["success"]
+ assert response["result"] == {"hello": "yo"}
+
+ assert len(events) == 0
+
+ # Fake new data to see we fire event
+ with patch(
+ "homeassistant.components.lovelace.dashboard.load_yaml",
+ return_value={"hello": "yo2"},
+ ):
+ await client.send_json({"id": 8, "type": "lovelace/config", "force": True})
+ response = await client.receive_json()
+
+ assert response["success"]
+ assert response["result"] == {"hello": "yo2"}
+
+ assert len(events) == 1
+
+
+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"}
+
+
+async def test_system_health_info_storage(hass, hass_storage):
+ """Test system health info endpoint."""
+ hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
+ "key": "lovelace",
+ "version": 1,
+ "data": {"config": {"resources": [], "views": []}},
+ }
+ assert await async_setup_component(hass, "lovelace", {})
+ info = await get_system_health_info(hass, "lovelace")
+ assert info == {"mode": "storage", "resources": 0, "views": 0}
+
+
+async def test_system_health_info_yaml(hass):
+ """Test system health info endpoint."""
+ assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}})
+ with patch(
+ "homeassistant.components.lovelace.dashboard.load_yaml",
+ return_value={"views": [{"cards": []}]},
+ ):
+ info = await get_system_health_info(hass, "lovelace")
+ assert info == {"mode": "yaml", "resources": 0, "views": 1}
+
+
+async def test_system_health_info_yaml_not_found(hass):
+ """Test system health info endpoint."""
+ assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}})
+ info = await get_system_health_info(hass, "lovelace")
+ assert info == {
+ "mode": "yaml",
+ "error": "{} not found".format(hass.config.path("ui-lovelace.yaml")),
+ }
+
+
+@pytest.mark.parametrize("url_path", ("test-panel", "test-panel-no-sidebar"))
+async def test_dashboard_from_yaml(hass, hass_ws_client, url_path):
+ """Test we load lovelace dashboard config from yaml."""
+ assert await async_setup_component(
+ hass,
+ "lovelace",
+ {
+ "lovelace": {
+ "dashboards": {
+ "test-panel": {
+ "mode": "yaml",
+ "filename": "bla.yaml",
+ "title": "Test Panel",
+ "icon": "mdi:test-icon",
+ "show_in_sidebar": False,
+ "require_admin": True,
+ },
+ "test-panel-no-sidebar": {
+ "title": "Title No Sidebar",
+ "mode": "yaml",
+ "filename": "bla2.yaml",
+ },
+ }
+ }
+ },
+ )
+ assert hass.data[frontend.DATA_PANELS]["test-panel"].config == {"mode": "yaml"}
+ assert hass.data[frontend.DATA_PANELS]["test-panel-no-sidebar"].config == {
+ "mode": "yaml"
+ }
+
+ client = await hass_ws_client(hass)
+
+ # List dashboards
+ await client.send_json({"id": 4, "type": "lovelace/dashboards/list"})
+ response = await client.receive_json()
+ assert response["success"]
+ assert len(response["result"]) == 2
+ with_sb, without_sb = response["result"]
+
+ assert with_sb["mode"] == "yaml"
+ assert with_sb["filename"] == "bla.yaml"
+ assert with_sb["title"] == "Test Panel"
+ assert with_sb["icon"] == "mdi:test-icon"
+ assert with_sb["show_in_sidebar"] is False
+ assert with_sb["require_admin"] is True
+ assert with_sb["url_path"] == "test-panel"
+
+ assert without_sb["mode"] == "yaml"
+ assert without_sb["filename"] == "bla2.yaml"
+ assert without_sb["show_in_sidebar"] is True
+ assert without_sb["require_admin"] is False
+ assert without_sb["url_path"] == "test-panel-no-sidebar"
+
+ # Fetch data
+ await client.send_json({"id": 5, "type": "lovelace/config", "url_path": url_path})
+ response = await client.receive_json()
+ assert not response["success"]
+
+ assert response["error"]["code"] == "config_not_found"
+
+ # Store new config not allowed
+ await client.send_json(
+ {
+ "id": 6,
+ "type": "lovelace/config/save",
+ "config": {"yo": "hello"},
+ "url_path": url_path,
+ }
+ )
+ response = await client.receive_json()
+ assert not response["success"]
+
+ # Patch data
+ events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED)
+
+ with patch(
+ "homeassistant.components.lovelace.dashboard.load_yaml",
+ return_value={"hello": "yo"},
+ ):
+ await client.send_json(
+ {"id": 7, "type": "lovelace/config", "url_path": url_path}
+ )
+ response = await client.receive_json()
+
+ assert response["success"]
+ assert response["result"] == {"hello": "yo"}
+
+ assert len(events) == 0
+
+ # Fake new data to see we fire event
+ with patch(
+ "homeassistant.components.lovelace.dashboard.load_yaml",
+ return_value={"hello": "yo2"},
+ ):
+ await client.send_json(
+ {"id": 8, "type": "lovelace/config", "force": True, "url_path": url_path}
+ )
+ response = await client.receive_json()
+
+ assert response["success"]
+ assert response["result"] == {"hello": "yo2"}
+
+ assert len(events) == 1
+
+
+async def test_wrong_key_dashboard_from_yaml(hass):
+ """Test we don't load lovelace dashboard without hyphen config from yaml."""
+ with assert_setup_component(0):
+ assert not await async_setup_component(
+ hass,
+ "lovelace",
+ {
+ "lovelace": {
+ "dashboards": {
+ "testpanel": {
+ "mode": "yaml",
+ "filename": "bla.yaml",
+ "title": "Test Panel",
+ "icon": "mdi:test-icon",
+ "show_in_sidebar": False,
+ "require_admin": True,
+ }
+ }
+ }
+ },
+ )
+
+
+async def test_storage_dashboards(hass, hass_ws_client, hass_storage):
+ """Test we load lovelace config from storage."""
+ assert await async_setup_component(hass, "lovelace", {})
+ assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "storage"}
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({"id": 5, "type": "lovelace/dashboards/list"})
+ response = await client.receive_json()
+ assert response["success"]
+ assert response["result"] == []
+
+ # Add a wrong dashboard
+ await client.send_json(
+ {
+ "id": 6,
+ "type": "lovelace/dashboards/create",
+ "url_path": "path",
+ "title": "Test path without hyphen",
+ }
+ )
+ response = await client.receive_json()
+ assert not response["success"]
+
+ # Add a dashboard
+ await client.send_json(
+ {
+ "id": 7,
+ "type": "lovelace/dashboards/create",
+ "url_path": "created-url-path",
+ "require_admin": True,
+ "title": "New Title",
+ "icon": "mdi:map",
+ }
+ )
+ response = await client.receive_json()
+ assert response["success"]
+ assert response["result"]["require_admin"] is True
+ assert response["result"]["title"] == "New Title"
+ assert response["result"]["icon"] == "mdi:map"
+
+ dashboard_id = response["result"]["id"]
+
+ assert "created-url-path" in hass.data[frontend.DATA_PANELS]
+
+ await client.send_json({"id": 8, "type": "lovelace/dashboards/list"})
+ response = await client.receive_json()
+ assert response["success"]
+ assert len(response["result"]) == 1
+ assert response["result"][0]["mode"] == "storage"
+ assert response["result"][0]["title"] == "New Title"
+ assert response["result"][0]["icon"] == "mdi:map"
+ assert response["result"][0]["show_in_sidebar"] is True
+ assert response["result"][0]["require_admin"] is True
+
+ # Fetch config
+ await client.send_json(
+ {"id": 9, "type": "lovelace/config", "url_path": "created-url-path"}
+ )
+ response = await client.receive_json()
+ assert not response["success"]
+ assert response["error"]["code"] == "config_not_found"
+
+ # Store new config
+ events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED)
+
+ await client.send_json(
+ {
+ "id": 10,
+ "type": "lovelace/config/save",
+ "url_path": "created-url-path",
+ "config": {"yo": "hello"},
+ }
+ )
+ response = await client.receive_json()
+ assert response["success"]
+ assert hass_storage[dashboard.CONFIG_STORAGE_KEY.format(dashboard_id)]["data"] == {
+ "config": {"yo": "hello"}
+ }
+ assert len(events) == 1
+ assert events[0].data["url_path"] == "created-url-path"
+
+ await client.send_json(
+ {"id": 11, "type": "lovelace/config", "url_path": "created-url-path"}
+ )
+ response = await client.receive_json()
+ assert response["success"]
+ assert response["result"] == {"yo": "hello"}
+
+ # Update a dashboard
+ await client.send_json(
+ {
+ "id": 12,
+ "type": "lovelace/dashboards/update",
+ "dashboard_id": dashboard_id,
+ "require_admin": False,
+ "icon": "mdi:updated",
+ "show_in_sidebar": False,
+ "title": "Updated Title",
+ }
+ )
+ response = await client.receive_json()
+ assert response["success"]
+ assert response["result"]["mode"] == "storage"
+ assert response["result"]["url_path"] == "created-url-path"
+ assert response["result"]["title"] == "Updated Title"
+ assert response["result"]["icon"] == "mdi:updated"
+ assert response["result"]["show_in_sidebar"] is False
+ assert response["result"]["require_admin"] is False
+
+ # List dashboards again and make sure we see latest config
+ await client.send_json({"id": 13, "type": "lovelace/dashboards/list"})
+ response = await client.receive_json()
+ assert response["success"]
+ assert len(response["result"]) == 1
+ assert response["result"][0]["mode"] == "storage"
+ assert response["result"][0]["url_path"] == "created-url-path"
+ assert response["result"][0]["title"] == "Updated Title"
+ assert response["result"][0]["icon"] == "mdi:updated"
+ assert response["result"][0]["show_in_sidebar"] is False
+ assert response["result"][0]["require_admin"] is False
+
+ # Add dashboard with existing url path
+ await client.send_json(
+ {"id": 14, "type": "lovelace/dashboards/create", "url_path": "created-url-path"}
+ )
+ response = await client.receive_json()
+ assert not response["success"]
+
+ # Delete dashboards
+ await client.send_json(
+ {"id": 15, "type": "lovelace/dashboards/delete", "dashboard_id": dashboard_id}
+ )
+ response = await client.receive_json()
+ assert response["success"]
+
+ assert "created-url-path" not in hass.data[frontend.DATA_PANELS]
+ assert dashboard.CONFIG_STORAGE_KEY.format(dashboard_id) not in hass_storage
+
+
+async def test_storage_dashboard_migrate(hass, hass_ws_client, hass_storage):
+ """Test changing url path from storage config."""
+ hass_storage[dashboard.DASHBOARDS_STORAGE_KEY] = {
+ "key": "lovelace_dashboards",
+ "version": 1,
+ "data": {
+ "items": [
+ {
+ "icon": "mdi:tools",
+ "id": "tools",
+ "mode": "storage",
+ "require_admin": True,
+ "show_in_sidebar": True,
+ "title": "Tools",
+ "url_path": "tools",
+ },
+ {
+ "icon": "mdi:tools",
+ "id": "tools2",
+ "mode": "storage",
+ "require_admin": True,
+ "show_in_sidebar": True,
+ "title": "Tools",
+ "url_path": "dashboard-tools",
+ },
+ ]
+ },
+ }
+
+ assert await async_setup_component(hass, "lovelace", {})
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({"id": 5, "type": "lovelace/dashboards/list"})
+ response = await client.receive_json()
+ assert response["success"]
+ without_hyphen, with_hyphen = response["result"]
+
+ assert without_hyphen["icon"] == "mdi:tools"
+ assert without_hyphen["id"] == "tools"
+ assert without_hyphen["mode"] == "storage"
+ assert without_hyphen["require_admin"]
+ assert without_hyphen["show_in_sidebar"]
+ assert without_hyphen["title"] == "Tools"
+ assert without_hyphen["url_path"] == "lovelace-tools"
+
+ assert (
+ with_hyphen
+ == hass_storage[dashboard.DASHBOARDS_STORAGE_KEY]["data"]["items"][1]
+ )
+
+
+async def test_websocket_list_dashboards(hass, hass_ws_client):
+ """Test listing dashboards both storage + YAML."""
+ assert await async_setup_component(
+ hass,
+ "lovelace",
+ {
+ "lovelace": {
+ "dashboards": {
+ "test-panel-no-sidebar": {
+ "title": "Test YAML",
+ "mode": "yaml",
+ "filename": "bla.yaml",
+ },
+ }
+ }
+ },
+ )
+
+ client = await hass_ws_client(hass)
+
+ # Create a storage dashboard
+ await client.send_json(
+ {
+ "id": 6,
+ "type": "lovelace/dashboards/create",
+ "url_path": "created-url-path",
+ "title": "Test Storage",
+ }
+ )
+ response = await client.receive_json()
+ assert response["success"]
+
+ # List dashboards
+ await client.send_json({"id": 8, "type": "lovelace/dashboards/list"})
+ response = await client.receive_json()
+ assert response["success"]
+ assert len(response["result"]) == 2
+ with_sb, without_sb = response["result"]
+
+ assert with_sb["mode"] == "yaml"
+ assert with_sb["title"] == "Test YAML"
+ assert with_sb["filename"] == "bla.yaml"
+ assert with_sb["url_path"] == "test-panel-no-sidebar"
+
+ assert without_sb["mode"] == "storage"
+ assert without_sb["title"] == "Test Storage"
+ assert without_sb["url_path"] == "created-url-path"
diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py
deleted file mode 100644
index c79e447f5af..00000000000
--- a/tests/components/lovelace/test_init.py
+++ /dev/null
@@ -1,186 +0,0 @@
-"""Test the Lovelace initialization."""
-from unittest.mock import patch
-
-from homeassistant.components import frontend, lovelace
-from homeassistant.setup import async_setup_component
-
-from tests.common import async_capture_events, get_system_health_info
-
-
-async def test_lovelace_from_storage(hass, hass_ws_client, hass_storage):
- """Test we load lovelace config from storage."""
- assert await async_setup_component(hass, "lovelace", {})
- assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "storage"}
-
- client = await hass_ws_client(hass)
-
- # Fetch data
- await client.send_json({"id": 5, "type": "lovelace/config"})
- response = await client.receive_json()
- assert not response["success"]
- assert response["error"]["code"] == "config_not_found"
-
- # Store new config
- events = async_capture_events(hass, lovelace.EVENT_LOVELACE_UPDATED)
-
- await client.send_json(
- {"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}}
- )
- response = await client.receive_json()
- assert response["success"]
- assert hass_storage[lovelace.STORAGE_KEY]["data"] == {"config": {"yo": "hello"}}
- assert len(events) == 1
-
- # Load new config
- await client.send_json({"id": 7, "type": "lovelace/config"})
- response = await client.receive_json()
- assert response["success"]
-
- assert response["result"] == {"yo": "hello"}
-
- # Test with safe mode
- hass.config.safe_mode = True
- await client.send_json({"id": 8, "type": "lovelace/config"})
- response = await client.receive_json()
- assert not response["success"]
- assert response["error"]["code"] == "config_not_found"
-
- await client.send_json(
- {"id": 9, "type": "lovelace/config/save", "config": {"yo": "hello"}}
- )
- response = await client.receive_json()
- assert not response["success"]
-
- await client.send_json({"id": 10, "type": "lovelace/config/delete"})
- response = await client.receive_json()
- assert not response["success"]
-
-
-async def test_lovelace_from_storage_save_before_load(
- hass, hass_ws_client, hass_storage
-):
- """Test we can load lovelace config from storage."""
- assert await async_setup_component(hass, "lovelace", {})
- client = await hass_ws_client(hass)
-
- # Store new config
- await client.send_json(
- {"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}}
- )
- response = await client.receive_json()
- assert response["success"]
- assert hass_storage[lovelace.STORAGE_KEY]["data"] == {"config": {"yo": "hello"}}
-
-
-async def test_lovelace_from_storage_delete(hass, hass_ws_client, hass_storage):
- """Test we delete lovelace config from storage."""
- assert await async_setup_component(hass, "lovelace", {})
- client = await hass_ws_client(hass)
-
- # Store new config
- await client.send_json(
- {"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}}
- )
- response = await client.receive_json()
- assert response["success"]
- assert hass_storage[lovelace.STORAGE_KEY]["data"] == {"config": {"yo": "hello"}}
-
- # Delete config
- await client.send_json({"id": 7, "type": "lovelace/config/delete"})
- response = await client.receive_json()
- assert response["success"]
- assert hass_storage[lovelace.STORAGE_KEY]["data"] == {"config": None}
-
- # Fetch data
- await client.send_json({"id": 8, "type": "lovelace/config"})
- response = await client.receive_json()
- assert not response["success"]
- assert response["error"]["code"] == "config_not_found"
-
-
-async def test_lovelace_from_yaml(hass, hass_ws_client):
- """Test we load lovelace config from yaml."""
- assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}})
- assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "yaml"}
-
- client = await hass_ws_client(hass)
-
- # Fetch data
- await client.send_json({"id": 5, "type": "lovelace/config"})
- response = await client.receive_json()
- assert not response["success"]
-
- assert response["error"]["code"] == "config_not_found"
-
- # Store new config not allowed
- await client.send_json(
- {"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}}
- )
- response = await client.receive_json()
- assert not response["success"]
-
- # Patch data
- events = async_capture_events(hass, lovelace.EVENT_LOVELACE_UPDATED)
-
- with patch(
- "homeassistant.components.lovelace.load_yaml", return_value={"hello": "yo"}
- ):
- await client.send_json({"id": 7, "type": "lovelace/config"})
- response = await client.receive_json()
-
- assert response["success"]
- assert response["result"] == {"hello": "yo"}
-
- assert len(events) == 0
-
- # Fake new data to see we fire event
- with patch(
- "homeassistant.components.lovelace.load_yaml", return_value={"hello": "yo2"}
- ):
- await client.send_json({"id": 8, "type": "lovelace/config", "force": True})
- response = await client.receive_json()
-
- assert response["success"]
- assert response["result"] == {"hello": "yo2"}
-
- assert len(events) == 1
-
-
-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"}
-
-
-async def test_system_health_info_storage(hass, hass_storage):
- """Test system health info endpoint."""
- hass_storage[lovelace.STORAGE_KEY] = {
- "key": "lovelace",
- "version": 1,
- "data": {"config": {"resources": [], "views": []}},
- }
- assert await async_setup_component(hass, "lovelace", {})
- info = await get_system_health_info(hass, "lovelace")
- assert info == {"mode": "storage", "resources": 0, "views": 0}
-
-
-async def test_system_health_info_yaml(hass):
- """Test system health info endpoint."""
- assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}})
- with patch(
- "homeassistant.components.lovelace.load_yaml",
- return_value={"views": [{"cards": []}]},
- ):
- info = await get_system_health_info(hass, "lovelace")
- assert info == {"mode": "yaml", "resources": 0, "views": 1}
-
-
-async def test_system_health_info_yaml_not_found(hass):
- """Test system health info endpoint."""
- assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}})
- info = await get_system_health_info(hass, "lovelace")
- assert info == {
- "mode": "yaml",
- "error": "{} not found".format(hass.config.path("ui-lovelace.yaml")),
- }
diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py
new file mode 100644
index 00000000000..a44af14d3a0
--- /dev/null
+++ b/tests/components/lovelace/test_resources.py
@@ -0,0 +1,174 @@
+"""Test Lovelace resources."""
+import copy
+import uuid
+
+from asynctest import patch
+
+from homeassistant.components.lovelace import dashboard, resources
+from homeassistant.setup import async_setup_component
+
+RESOURCE_EXAMPLES = [
+ {"type": "js", "url": "/local/bla.js"},
+ {"type": "css", "url": "/local/bla.css"},
+]
+
+
+async def test_yaml_resources(hass, hass_ws_client):
+ """Test defining resources in configuration.yaml."""
+ assert await async_setup_component(
+ hass, "lovelace", {"lovelace": {"mode": "yaml", "resources": RESOURCE_EXAMPLES}}
+ )
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({"id": 5, "type": "lovelace/resources"})
+ response = await client.receive_json()
+ assert response["success"]
+ assert response["result"] == RESOURCE_EXAMPLES
+
+
+async def test_yaml_resources_backwards(hass, hass_ws_client):
+ """Test defining resources in YAML ll config (legacy)."""
+ with patch(
+ "homeassistant.components.lovelace.dashboard.load_yaml",
+ return_value={"resources": RESOURCE_EXAMPLES},
+ ):
+ assert await async_setup_component(
+ hass, "lovelace", {"lovelace": {"mode": "yaml"}}
+ )
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({"id": 5, "type": "lovelace/resources"})
+ response = await client.receive_json()
+ assert response["success"]
+ assert response["result"] == RESOURCE_EXAMPLES
+
+
+async def test_storage_resources(hass, hass_ws_client, hass_storage):
+ """Test defining resources in storage config."""
+ resource_config = [{**item, "id": uuid.uuid4().hex} for item in RESOURCE_EXAMPLES]
+ hass_storage[resources.RESOURCE_STORAGE_KEY] = {
+ "key": resources.RESOURCE_STORAGE_KEY,
+ "version": 1,
+ "data": {"items": resource_config},
+ }
+ assert await async_setup_component(hass, "lovelace", {})
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({"id": 5, "type": "lovelace/resources"})
+ response = await client.receive_json()
+ assert response["success"]
+ assert response["result"] == resource_config
+
+
+async def test_storage_resources_import(hass, hass_ws_client, hass_storage):
+ """Test importing resources from storage config."""
+ assert await async_setup_component(hass, "lovelace", {})
+ hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
+ "key": "lovelace",
+ "version": 1,
+ "data": {"config": {"resources": copy.deepcopy(RESOURCE_EXAMPLES)}},
+ }
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({"id": 5, "type": "lovelace/resources"})
+ response = await client.receive_json()
+ assert response["success"]
+ assert (
+ response["result"]
+ == hass_storage[resources.RESOURCE_STORAGE_KEY]["data"]["items"]
+ )
+ assert (
+ "resources"
+ not in hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"]["config"]
+ )
+
+ # Add a resource
+ await client.send_json(
+ {
+ "id": 6,
+ "type": "lovelace/resources/create",
+ "res_type": "module",
+ "url": "/local/yo.js",
+ }
+ )
+ response = await client.receive_json()
+ assert response["success"]
+
+ await client.send_json({"id": 7, "type": "lovelace/resources"})
+ response = await client.receive_json()
+ assert response["success"]
+
+ last_item = response["result"][-1]
+ assert last_item["type"] == "module"
+ assert last_item["url"] == "/local/yo.js"
+
+ # Update a resource
+ first_item = response["result"][0]
+
+ await client.send_json(
+ {
+ "id": 8,
+ "type": "lovelace/resources/update",
+ "resource_id": first_item["id"],
+ "res_type": "css",
+ "url": "/local/updated.css",
+ }
+ )
+ response = await client.receive_json()
+ assert response["success"]
+
+ await client.send_json({"id": 9, "type": "lovelace/resources"})
+ response = await client.receive_json()
+ assert response["success"]
+
+ first_item = response["result"][0]
+ assert first_item["type"] == "css"
+ assert first_item["url"] == "/local/updated.css"
+
+ # Delete resources
+ await client.send_json(
+ {
+ "id": 10,
+ "type": "lovelace/resources/delete",
+ "resource_id": first_item["id"],
+ }
+ )
+ response = await client.receive_json()
+ assert response["success"]
+
+ await client.send_json({"id": 11, "type": "lovelace/resources"})
+ response = await client.receive_json()
+ assert response["success"]
+
+ assert len(response["result"]) == 2
+ assert first_item["id"] not in (item["id"] for item in response["result"])
+
+
+async def test_storage_resources_import_invalid(hass, hass_ws_client, hass_storage):
+ """Test importing resources from storage config."""
+ assert await async_setup_component(hass, "lovelace", {})
+ hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
+ "key": "lovelace",
+ "version": 1,
+ "data": {"config": {"resources": [{"invalid": "resource"}]}},
+ }
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({"id": 5, "type": "lovelace/resources"})
+ response = await client.receive_json()
+ assert response["success"]
+ assert response["result"] == []
+ assert (
+ "resources"
+ in hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"]["config"]
+ )
diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py
index 3db92cda42d..f3d8ec3298a 100644
--- a/tests/components/media_player/test_init.py
+++ b/tests/components/media_player/test_init.py
@@ -1,6 +1,7 @@
"""Test the base functions of the media player."""
import base64
-from unittest.mock import patch
+
+from asynctest import patch
from homeassistant.components.websocket_api.const import TYPE_RESULT
from homeassistant.setup import async_setup_component
@@ -8,7 +9,7 @@ from homeassistant.setup import async_setup_component
from tests.common import mock_coro
-async def test_get_image(hass, hass_ws_client):
+async def test_get_image(hass, hass_ws_client, caplog):
"""Test get image via WS command."""
await async_setup_component(
hass, "media_player", {"media_player": {"platform": "demo"}}
@@ -37,43 +38,53 @@ async def test_get_image(hass, hass_ws_client):
assert msg["result"]["content_type"] == "image/jpeg"
assert msg["result"]["content"] == base64.b64encode(b"image").decode("utf-8")
+ assert "media_player_thumbnail is deprecated" in caplog.text
-async def test_get_image_http(hass, hass_client):
+
+async def test_get_image_http(hass, aiohttp_client):
"""Test get image via http command."""
await async_setup_component(
hass, "media_player", {"media_player": {"platform": "demo"}}
)
- client = await hass_client()
+ state = hass.states.get("media_player.bedroom")
+ assert "entity_picture_local" not in state.attributes
+
+ client = await aiohttp_client(hass.http.app)
with patch(
"homeassistant.components.media_player.MediaPlayerDevice."
"async_get_media_image",
- return_value=mock_coro((b"image", "image/jpeg")),
+ return_value=(b"image", "image/jpeg"),
):
- resp = await client.get("/api/media_player_proxy/media_player.bedroom")
+ resp = await client.get(state.attributes["entity_picture"])
content = await resp.read()
assert content == b"image"
-async def test_get_image_http_url(hass, hass_client):
+async def test_get_image_http_remote(hass, aiohttp_client):
"""Test get image url via http command."""
- await async_setup_component(
- hass, "media_player", {"media_player": {"platform": "demo"}}
- )
-
- client = await hass_client()
-
with patch(
"homeassistant.components.media_player.MediaPlayerDevice."
"media_image_remotely_accessible",
return_value=True,
):
- resp = await client.get(
- "/api/media_player_proxy/media_player.bedroom", allow_redirects=False
- )
- assert (
- resp.headers["Location"]
- == "https://img.youtube.com/vi/kxopViU98Xo/hqdefault.jpg"
+ await async_setup_component(
+ hass, "media_player", {"media_player": {"platform": "demo"}}
)
+
+ state = hass.states.get("media_player.bedroom")
+ assert "entity_picture_local" in state.attributes
+
+ client = await aiohttp_client(hass.http.app)
+
+ with patch(
+ "homeassistant.components.media_player.MediaPlayerDevice."
+ "async_get_media_image",
+ return_value=(b"image", "image/jpeg"),
+ ):
+ resp = await client.get(state.attributes["entity_picture_local"])
+ content = await resp.read()
+
+ assert content == b"image"
diff --git a/tests/components/met/test_config_flow.py b/tests/components/met/test_config_flow.py
index 8a81d137672..980994f3fb2 100644
--- a/tests/components/met/test_config_flow.py
+++ b/tests/components/met/test_config_flow.py
@@ -73,11 +73,10 @@ async def test_flow_entry_already_exists(hass):
Test when the form should show when user puts existing location
in the config gui. Then the form should show with error.
"""
- first_entry = MockConfigEntry(domain="met")
- first_entry.data["name"] = "home"
- first_entry.data[CONF_LONGITUDE] = 0
- first_entry.data[CONF_LATITUDE] = 0
- first_entry.data[CONF_ELEVATION] = 0
+ first_entry = MockConfigEntry(
+ domain="met",
+ data={"name": "home", CONF_LATITUDE: 0, CONF_LONGITUDE: 0, CONF_ELEVATION: 0},
+ )
first_entry.add_to_hass(hass)
test_data = {
diff --git a/tests/components/mhz19/test_sensor.py b/tests/components/mhz19/test_sensor.py
index 5eab93a30ff..45413a10cda 100644
--- a/tests/components/mhz19/test_sensor.py
+++ b/tests/components/mhz19/test_sensor.py
@@ -4,7 +4,7 @@ from unittest.mock import DEFAULT, Mock, patch
import homeassistant.components.mhz19.sensor as mhz19
from homeassistant.components.sensor import DOMAIN
-from homeassistant.const import TEMP_FAHRENHEIT
+from homeassistant.const import CONCENTRATION_PARTS_PER_MILLION, TEMP_FAHRENHEIT
from homeassistant.setup import setup_component
from tests.common import assert_setup_component, get_test_home_assistant
@@ -100,7 +100,7 @@ class TestMHZ19Sensor(unittest.TestCase):
assert "name: CO2" == sensor.name
assert 1000 == sensor.state
- assert "ppm" == sensor.unit_of_measurement
+ assert CONCENTRATION_PARTS_PER_MILLION == sensor.unit_of_measurement
assert sensor.should_poll
assert {"temperature": 24} == sensor.device_state_attributes
diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py
index fc37c9113ae..9a2f75f7015 100644
--- a/tests/components/mikrotik/test_hub.py
+++ b/tests/components/mikrotik/test_hub.py
@@ -33,10 +33,10 @@ async def setup_mikrotik_entry(hass, **kwargs):
config_entry.add_to_hass(hass)
if "force_dhcp" in kwargs:
- config_entry.options["force_dhcp"] = True
+ config_entry.options = {**config_entry.options, "force_dhcp": True}
if "arp_ping" in kwargs:
- config_entry.options["arp_ping"] = True
+ config_entry.options = {**config_entry.options, "arp_ping": True}
with patch("librouteros.connect"), patch.object(
mikrotik.hub.MikrotikData, "command", new=mock_command
diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py
index fcc9a72cf7b..38b725226e7 100644
--- a/tests/components/min_max/test_sensor.py
+++ b/tests/components/min_max/test_sensor.py
@@ -7,6 +7,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
)
from homeassistant.setup import setup_component
@@ -233,7 +234,7 @@ class TestMinMaxSensor(unittest.TestCase):
assert "ERR" == state.attributes.get("unit_of_measurement")
self.hass.states.set(
- entity_ids[2], self.values[2], {ATTR_UNIT_OF_MEASUREMENT: "%"}
+ entity_ids[2], self.values[2], {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE}
)
self.hass.block_till_done()
diff --git a/tests/components/mobile_app/test_entity.py b/tests/components/mobile_app/test_entity.py
index 65dc328186d..27ce29ecd15 100644
--- a/tests/components/mobile_app/test_entity.py
+++ b/tests/components/mobile_app/test_entity.py
@@ -2,6 +2,7 @@
import logging
+from homeassistant.const import UNIT_PERCENTAGE
from homeassistant.helpers import device_registry
_LOGGER = logging.getLogger(__name__)
@@ -24,7 +25,7 @@ async def test_sensor(hass, create_registrations, webhook_client):
"state": 100,
"type": "sensor",
"unique_id": "battery_state",
- "unit_of_measurement": "%",
+ "unit_of_measurement": UNIT_PERCENTAGE,
},
},
)
@@ -40,7 +41,7 @@ async def test_sensor(hass, create_registrations, webhook_client):
assert entity.attributes["device_class"] == "battery"
assert entity.attributes["icon"] == "mdi:battery"
- assert entity.attributes["unit_of_measurement"] == "%"
+ assert entity.attributes["unit_of_measurement"] == UNIT_PERCENTAGE
assert entity.attributes["foo"] == "bar"
assert entity.domain == "sensor"
assert entity.name == "Test 1 Battery State"
@@ -104,7 +105,7 @@ async def test_sensor_id_no_dupes(hass, create_registrations, webhook_client):
"state": 100,
"type": "sensor",
"unique_id": "battery_state",
- "unit_of_measurement": "%",
+ "unit_of_measurement": UNIT_PERCENTAGE,
},
}
diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py
index 39837543a47..974fb577606 100644
--- a/tests/components/mobile_app/test_webhook.py
+++ b/tests/components/mobile_app/test_webhook.py
@@ -160,8 +160,10 @@ async def test_webhook_handle_get_zones(hass, create_registrations, webhook_clie
assert resp.status == 200
json = await resp.json()
- assert len(json) == 1
- assert json[0]["entity_id"] == "zone.home"
+ assert len(json) == 2
+ zones = sorted(json, key=lambda entry: entry["entity_id"])
+ assert zones[0]["entity_id"] == "zone.home"
+ assert zones[1]["entity_id"] == "zone.test"
async def test_webhook_handle_get_config(hass, create_registrations, webhook_client):
diff --git a/tests/components/mold_indicator/test_sensor.py b/tests/components/mold_indicator/test_sensor.py
index ce0450d3304..bad7430e9b2 100644
--- a/tests/components/mold_indicator/test_sensor.py
+++ b/tests/components/mold_indicator/test_sensor.py
@@ -6,7 +6,12 @@ from homeassistant.components.mold_indicator.sensor import (
ATTR_DEWPOINT,
)
import homeassistant.components.sensor as sensor
-from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, TEMP_CELSIUS
+from homeassistant.const import (
+ ATTR_UNIT_OF_MEASUREMENT,
+ STATE_UNKNOWN,
+ TEMP_CELSIUS,
+ UNIT_PERCENTAGE,
+)
from homeassistant.setup import setup_component
from tests.common import get_test_home_assistant
@@ -25,7 +30,7 @@ class TestSensorMoldIndicator(unittest.TestCase):
"test.outdoortemp", "10", {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}
)
self.hass.states.set(
- "test.indoorhumidity", "50", {ATTR_UNIT_OF_MEASUREMENT: "%"}
+ "test.indoorhumidity", "50", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE}
)
def tearDown(self):
@@ -50,7 +55,7 @@ class TestSensorMoldIndicator(unittest.TestCase):
moldind = self.hass.states.get("sensor.mold_indicator")
assert moldind
- assert "%" == moldind.attributes.get("unit_of_measurement")
+ assert UNIT_PERCENTAGE == moldind.attributes.get("unit_of_measurement")
def test_invalidcalib(self):
"""Test invalid sensor values."""
@@ -61,7 +66,7 @@ class TestSensorMoldIndicator(unittest.TestCase):
"test.outdoortemp", "10", {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}
)
self.hass.states.set(
- "test.indoorhumidity", "0", {ATTR_UNIT_OF_MEASUREMENT: "%"}
+ "test.indoorhumidity", "0", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE}
)
assert setup_component(
@@ -94,7 +99,7 @@ class TestSensorMoldIndicator(unittest.TestCase):
"test.outdoortemp", "10", {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}
)
self.hass.states.set(
- "test.indoorhumidity", "-1", {ATTR_UNIT_OF_MEASUREMENT: "%"}
+ "test.indoorhumidity", "-1", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE}
)
assert setup_component(
@@ -120,7 +125,7 @@ class TestSensorMoldIndicator(unittest.TestCase):
assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None
self.hass.states.set(
- "test.indoorhumidity", "A", {ATTR_UNIT_OF_MEASUREMENT: "%"}
+ "test.indoorhumidity", "A", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE}
)
self.hass.block_till_done()
moldind = self.hass.states.get("sensor.mold_indicator")
@@ -220,7 +225,9 @@ class TestSensorMoldIndicator(unittest.TestCase):
"test.outdoortemp", "25", {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}
)
self.hass.states.set(
- "test.indoorhumidity", STATE_UNKNOWN, {ATTR_UNIT_OF_MEASUREMENT: "%"}
+ "test.indoorhumidity",
+ STATE_UNKNOWN,
+ {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE},
)
self.hass.block_till_done()
moldind = self.hass.states.get("sensor.mold_indicator")
@@ -230,7 +237,7 @@ class TestSensorMoldIndicator(unittest.TestCase):
assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None
self.hass.states.set(
- "test.indoorhumidity", "20", {ATTR_UNIT_OF_MEASUREMENT: "%"}
+ "test.indoorhumidity", "20", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE}
)
self.hass.block_till_done()
moldind = self.hass.states.get("sensor.mold_indicator")
@@ -276,7 +283,7 @@ class TestSensorMoldIndicator(unittest.TestCase):
assert self.hass.states.get("sensor.mold_indicator").state == "57"
self.hass.states.set(
- "test.indoorhumidity", "20", {ATTR_UNIT_OF_MEASUREMENT: "%"}
+ "test.indoorhumidity", "20", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE}
)
self.hass.block_till_done()
assert self.hass.states.get("sensor.mold_indicator").state == "23"
diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py
new file mode 100644
index 00000000000..f8de0faf82f
--- /dev/null
+++ b/tests/components/mqtt/common.py
@@ -0,0 +1,246 @@
+"""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 6ec28b04e4d..9a0df0bcd8d 100644
--- a/tests/components/mqtt/test_alarm_control_panel.py
+++ b/tests/components/mqtt/test_alarm_control_panel.py
@@ -1,9 +1,8 @@
"""The tests the MQTT alarm control panel component."""
+import copy
import json
-from unittest.mock import ANY
-from homeassistant.components import alarm_control_panel, mqtt
-from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.components import alarm_control_panel
from homeassistant.const import (
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_HOME,
@@ -15,18 +14,75 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
+from .common import (
+ help_test_discovery_broken,
+ help_test_discovery_removal,
+ help_test_discovery_update,
+ help_test_discovery_update_attr,
+ help_test_entity_device_info_update,
+ help_test_entity_device_info_with_identifier,
+ help_test_entity_id_update,
+ help_test_setting_attribute_via_mqtt_json_message,
+ 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,
- async_mock_mqtt_component,
async_setup_component,
- mock_registry,
)
from tests.components.alarm_control_panel import common
CODE = "HELLO_CODE"
+DEFAULT_CONFIG = {
+ alarm_control_panel.DOMAIN: {
+ "platform": "mqtt",
+ "name": "test",
+ "state_topic": "alarm/state",
+ "command_topic": "alarm/command",
+ }
+}
+
+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",
+ "name": "test",
+ "state_topic": "alarm/state",
+ "command_topic": "alarm/command",
+ "code": "1234",
+ "code_arm_required": True,
+ }
+}
+
+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."""
@@ -62,16 +118,7 @@ async def test_fail_setup_without_command_topic(hass, mqtt_mock):
async def test_update_state_via_state_topic(hass, mqtt_mock):
"""Test updating with via state topic."""
assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- }
- },
+ hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG,
)
entity_id = "alarm_control_panel.test"
@@ -93,16 +140,7 @@ async def test_update_state_via_state_topic(hass, mqtt_mock):
async def test_ignore_update_state_if_unknown_via_state_topic(hass, mqtt_mock):
"""Test ignoring updates via state topic."""
assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- }
- },
+ hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG,
)
entity_id = "alarm_control_panel.test"
@@ -116,16 +154,7 @@ async def test_ignore_update_state_if_unknown_via_state_topic(hass, mqtt_mock):
async def test_arm_home_publishes_mqtt(hass, mqtt_mock):
"""Test publishing of MQTT messages while armed."""
assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- }
- },
+ hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG,
)
await common.async_alarm_arm_home(hass)
@@ -140,18 +169,7 @@ async def test_arm_home_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt
When code_arm_required = True
"""
assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- "code": "1234",
- "code_arm_required": True,
- }
- },
+ hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE,
)
call_count = mqtt_mock.async_publish.call_count
@@ -164,20 +182,9 @@ async def test_arm_home_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
When code_arm_required = False
"""
- assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- "code": "1234",
- "code_arm_required": False,
- }
- },
- )
+ config = copy.deepcopy(DEFAULT_CONFIG_CODE)
+ config[alarm_control_panel.DOMAIN]["code_arm_required"] = False
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config,)
await common.async_alarm_arm_home(hass)
mqtt_mock.async_publish.assert_called_once_with(
@@ -188,16 +195,7 @@ async def test_arm_home_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
async def test_arm_away_publishes_mqtt(hass, mqtt_mock):
"""Test publishing of MQTT messages while armed."""
assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- }
- },
+ hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG,
)
await common.async_alarm_arm_away(hass)
@@ -212,18 +210,7 @@ async def test_arm_away_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt
When code_arm_required = True
"""
assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- "code": "1234",
- "code_arm_required": True,
- }
- },
+ hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE,
)
call_count = mqtt_mock.async_publish.call_count
@@ -236,20 +223,9 @@ async def test_arm_away_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
When code_arm_required = False
"""
- assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- "code": "1234",
- "code_arm_required": False,
- }
- },
- )
+ config = copy.deepcopy(DEFAULT_CONFIG_CODE)
+ config[alarm_control_panel.DOMAIN]["code_arm_required"] = False
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config,)
await common.async_alarm_arm_away(hass)
mqtt_mock.async_publish.assert_called_once_with(
@@ -260,16 +236,7 @@ async def test_arm_away_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
async def test_arm_night_publishes_mqtt(hass, mqtt_mock):
"""Test publishing of MQTT messages while armed."""
assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- }
- },
+ hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG,
)
await common.async_alarm_arm_night(hass)
@@ -284,18 +251,7 @@ async def test_arm_night_not_publishes_mqtt_with_invalid_code_when_req(hass, mqt
When code_arm_required = True
"""
assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- "code": "1234",
- "code_arm_required": True,
- }
- },
+ hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE,
)
call_count = mqtt_mock.async_publish.call_count
@@ -308,20 +264,9 @@ async def test_arm_night_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
When code_arm_required = False
"""
- assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- "code": "1234",
- "code_arm_required": False,
- }
- },
- )
+ config = copy.deepcopy(DEFAULT_CONFIG_CODE)
+ config[alarm_control_panel.DOMAIN]["code_arm_required"] = False
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config,)
await common.async_alarm_arm_night(hass)
mqtt_mock.async_publish.assert_called_once_with(
@@ -332,16 +277,7 @@ async def test_arm_night_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
async def test_disarm_publishes_mqtt(hass, mqtt_mock):
"""Test publishing of MQTT messages while disarmed."""
assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- }
- },
+ hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG,
)
await common.async_alarm_disarm(hass)
@@ -353,20 +289,12 @@ async def test_disarm_publishes_mqtt_with_template(hass, mqtt_mock):
When command_template set to output json
"""
- assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- "code": "1234",
- "command_template": '{"action":"{{ action }}",' '"code":"{{ code }}"}',
- }
- },
+ config = copy.deepcopy(DEFAULT_CONFIG_CODE)
+ config[alarm_control_panel.DOMAIN]["code"] = "1234"
+ config[alarm_control_panel.DOMAIN]["command_template"] = (
+ '{"action":"{{ action }}",' '"code":"{{ code }}"}'
)
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config,)
await common.async_alarm_disarm(hass, 1234)
mqtt_mock.async_publish.assert_called_once_with(
@@ -379,20 +307,10 @@ async def test_disarm_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
When code_disarm_required = False
"""
- assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- "code": "1234",
- "code_disarm_required": False,
- }
- },
- )
+ config = copy.deepcopy(DEFAULT_CONFIG_CODE)
+ config[alarm_control_panel.DOMAIN]["code"] = "1234"
+ config[alarm_control_panel.DOMAIN]["code_disarm_required"] = False
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config,)
await common.async_alarm_disarm(hass)
mqtt_mock.async_publish.assert_called_once_with("alarm/command", "DISARM", 0, False)
@@ -404,18 +322,7 @@ async def test_disarm_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt_m
When code_disarm_required = True
"""
assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- "code": "1234",
- "code_disarm_required": True,
- }
- },
+ hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE,
)
call_count = mqtt_mock.async_publish.call_count
@@ -425,20 +332,9 @@ async def test_disarm_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt_m
async def test_default_availability_payload(hass, mqtt_mock):
"""Test availability by default payload with defined topic."""
- assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- "code": "1234",
- "availability_topic": "availability-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
@@ -456,22 +352,11 @@ async def test_default_availability_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,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "alarm/state",
- "command_topic": "alarm/command",
- "code": "1234",
- "availability_topic": "availability-topic",
- "payload_available": "good",
- "payload_not_available": "nogood",
- }
- },
- )
+ 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
@@ -487,28 +372,6 @@ async def test_custom_availability_payload(hass, mqtt_mock):
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."""
- assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "state_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
- )
-
- async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
- state = hass.states.get("alarm_control_panel.test")
-
- assert state.attributes.get("val") == "100"
-
-
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(
@@ -539,319 +402,127 @@ async def test_update_state_via_state_topic_template(hass, mqtt_mock):
assert state.state == STATE_ALARM_ARMED_AWAY
-async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
- """Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "state_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+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
)
- async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]')
- state = hass.states.get("alarm_control_panel.test")
- assert state.attributes.get("val") is None
- assert "JSON result was not a dictionary" in caplog.text
+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
+ )
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "state_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_bad_JSON(
+ hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", "This is not JSON")
-
- state = hass.states.get("alarm_control_panel.test")
- assert state.attributes.get("val") is None
- assert "Erroneous JSON: This is not JSON" in caplog.text
-
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
- data1 = (
- '{ "name": "Beer",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic1" }'
+ 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
)
- data2 = (
- '{ "name": "Beer",'
- ' "command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic2" }'
- )
- async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data1)
- await hass.async_block_till_done()
- async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }')
- state = hass.states.get("alarm_control_panel.beer")
- assert state.attributes.get("val") == "100"
-
- # Change json_attributes_topic
- async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/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("alarm_control_panel.beer")
- 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("alarm_control_panel.beer")
- assert state.attributes.get("val") == "75"
async def test_unique_id(hass):
"""Test unique id option only creates one alarm per unique_id."""
- await async_mock_mqtt_component(hass)
- assert await async_setup_component(
- hass,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "Test 1",
- "state_topic": "test-topic",
- "command_topic": "test_topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- {
- "platform": "mqtt",
- "name": "Test 2",
- "state_topic": "test-topic",
- "command_topic": "test_topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- ]
- },
- )
- async_fire_mqtt_message(hass, "test-topic", "payload")
- assert len(hass.states.async_entity_ids(alarm_control_panel.DOMAIN)) == 1
+ config = {
+ alarm_control_panel.DOMAIN: [
+ {
+ "platform": "mqtt",
+ "name": "Test 1",
+ "state_topic": "test-topic",
+ "command_topic": "command-topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ {
+ "platform": "mqtt",
+ "name": "Test 2",
+ "state_topic": "test-topic",
+ "command_topic": "command-topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ ]
+ }
+ await help_test_unique_id(hass, alarm_control_panel.DOMAIN, config)
async def test_discovery_removal_alarm(hass, mqtt_mock, caplog):
"""Test removal of discovered alarm_control_panel."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
- data = (
- '{ "name": "Beer",'
- ' "state_topic": "test_topic",'
- ' "command_topic": "test_topic" }'
+ data = json.dumps(DEFAULT_CONFIG[alarm_control_panel.DOMAIN])
+ await help_test_discovery_removal(
+ hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data
)
- async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data)
- await hass.async_block_till_done()
-
- state = hass.states.get("alarm_control_panel.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", "")
- await hass.async_block_till_done()
-
- state = hass.states.get("alarm_control_panel.beer")
- assert state is None
-
async def test_discovery_update_alarm(hass, mqtt_mock, caplog):
"""Test update of discovered alarm_control_panel."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
+ config1 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN])
+ config2 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN])
+ config1["name"] = "Beer"
+ config2["name"] = "Milk"
- data1 = (
- '{ "name": "Beer",'
- ' "state_topic": "test_topic",'
- ' "command_topic": "test_topic" }'
+ data1 = json.dumps(config1)
+ data2 = json.dumps(config2)
+ await help_test_discovery_update(
+ hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data1, data2
)
- data2 = (
- '{ "name": "Milk",'
- ' "state_topic": "test_topic",'
- ' "command_topic": "test_topic" }'
- )
-
- async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("alarm_control_panel.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("alarm_control_panel.beer")
- assert state is not None
- assert state.name == "Milk"
-
- state = hass.states.get("alarm_control_panel.milk")
- assert state is None
async def test_discovery_broken(hass, mqtt_mock, caplog):
"""Test handling of bad discovery message."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
data1 = '{ "name": "Beer" }'
data2 = (
'{ "name": "Milk",'
' "state_topic": "test_topic",'
' "command_topic": "test_topic" }'
)
-
- async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("alarm_control_panel.beer")
- assert state is None
-
- async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("alarm_control_panel.milk")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("alarm_control_panel.beer")
- assert state is None
+ await help_test_discovery_broken(
+ hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data1, data2
+ )
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT alarm control panel 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",
- "state_topic": "test-topic",
- "command_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, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
)
- async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/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()
-
- config = {
- "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",
- }
-
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data)
- await hass.async_block_till_done()
-
- device = registry.async_get_device({("mqtt", "helloworld")}, set())
- assert device is not None
- assert device.name == "Beer"
-
- config["device"]["name"] = "Milk"
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/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"
+ await help_test_entity_device_info_update(
+ hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ )
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,
- alarm_control_panel.DOMAIN,
- {
- alarm_control_panel.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "state_topic": "test-topic",
- "command_topic": "command-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- },
+ 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
)
-
- state = hass.states.get("alarm_control_panel.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(
- "alarm_control_panel.beer", new_entity_id="alarm_control_panel.milk"
- )
- await hass.async_block_till_done()
-
- state = hass.states.get("alarm_control_panel.beer")
- assert state is None
-
- state = hass.states.get("alarm_control_panel.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_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py
index 3bfe32633b3..2cc917c527b 100644
--- a/tests/components/mqtt/test_binary_sensor.py
+++ b/tests/components/mqtt/test_binary_sensor.py
@@ -1,10 +1,10 @@
"""The tests for the MQTT binary sensor platform."""
+import copy
from datetime import datetime, timedelta
import json
-from unittest.mock import ANY, patch
+from unittest.mock import patch
-from homeassistant.components import binary_sensor, mqtt
-from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.components import binary_sensor
from homeassistant.const import (
EVENT_STATE_CHANGED,
STATE_OFF,
@@ -15,14 +15,54 @@ import homeassistant.core as ha
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
-from tests.common import (
- MockConfigEntry,
- async_fire_mqtt_message,
- async_fire_time_changed,
- async_mock_mqtt_component,
- mock_registry,
+from .common import (
+ help_test_discovery_broken,
+ help_test_discovery_removal,
+ help_test_discovery_update,
+ help_test_discovery_update_attr,
+ help_test_entity_device_info_update,
+ help_test_entity_device_info_with_identifier,
+ help_test_entity_id_update,
+ help_test_setting_attribute_via_mqtt_json_message,
+ help_test_unique_id,
+ help_test_update_with_json_attrs_bad_JSON,
+ help_test_update_with_json_attrs_not_dict,
)
+from tests.common import async_fire_mqtt_message, async_fire_time_changed
+
+DEFAULT_CONFIG = {
+ binary_sensor.DOMAIN: {
+ "platform": "mqtt",
+ "name": "test",
+ "state_topic": "test-topic",
+ }
+}
+
+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."""
@@ -417,317 +457,116 @@ 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."""
- assert await async_setup_component(
- hass,
- binary_sensor.DOMAIN,
- {
- binary_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, binary_sensor.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
- state = hass.states.get("binary_sensor.test")
-
- assert state.attributes.get("val") == "100"
-
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- binary_sensor.DOMAIN,
- {
- binary_sensor.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_not_dict(
+ hass, mqtt_mock, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]')
- state = hass.states.get("binary_sensor.test")
-
- assert state.attributes.get("val") is None
- assert "JSON result was not a dictionary" in caplog.text
-
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- binary_sensor.DOMAIN,
- {
- binary_sensor.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_bad_JSON(
+ hass, mqtt_mock, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", "This is not JSON")
-
- state = hass.states.get("binary_sensor.test")
- assert state.attributes.get("val") is None
- assert "Erroneous JSON: This is not JSON" in caplog.text
-
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
- data1 = (
- '{ "name": "Beer",'
- ' "state_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic1" }'
+ 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
)
- data2 = (
- '{ "name": "Beer",'
- ' "state_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic2" }'
- )
- async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data1)
- await hass.async_block_till_done()
- async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }')
- state = hass.states.get("binary_sensor.beer")
- assert state.attributes.get("val") == "100"
-
- # Change json_attributes_topic
- async_fire_mqtt_message(hass, "homeassistant/binary_sensor/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("binary_sensor.beer")
- 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("binary_sensor.beer")
- assert state.attributes.get("val") == "75"
async def test_unique_id(hass):
"""Test unique id option only creates one sensor per unique_id."""
- await async_mock_mqtt_component(hass)
- assert await async_setup_component(
- hass,
- binary_sensor.DOMAIN,
- {
- binary_sensor.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "Test 1",
- "state_topic": "test-topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- {
- "platform": "mqtt",
- "name": "Test 2",
- "state_topic": "test-topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- ]
- },
- )
- async_fire_mqtt_message(hass, "test-topic", "payload")
- assert len(hass.states.async_all()) == 1
+ config = {
+ binary_sensor.DOMAIN: [
+ {
+ "platform": "mqtt",
+ "name": "Test 1",
+ "state_topic": "test-topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ {
+ "platform": "mqtt",
+ "name": "Test 2",
+ "state_topic": "test-topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ ]
+ }
+ await help_test_unique_id(hass, binary_sensor.DOMAIN, config)
async def test_discovery_removal_binary_sensor(hass, mqtt_mock, caplog):
"""Test removal of discovered binary_sensor."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
- data = (
- '{ "name": "Beer",'
- ' "state_topic": "test_topic",'
- ' "availability_topic": "availability_topic" }'
+ data = json.dumps(DEFAULT_CONFIG[binary_sensor.DOMAIN])
+ await help_test_discovery_removal(
+ hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data
)
- async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data)
- await hass.async_block_till_done()
- state = hass.states.get("binary_sensor.beer")
- assert state is not None
- assert state.name == "Beer"
- async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "")
- await hass.async_block_till_done()
- state = hass.states.get("binary_sensor.beer")
- assert state is None
async def test_discovery_update_binary_sensor(hass, mqtt_mock, caplog):
"""Test update of discovered binary_sensor."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
- data1 = (
- '{ "name": "Beer",'
- ' "state_topic": "test_topic",'
- ' "availability_topic": "availability_topic1" }'
- )
- data2 = (
- '{ "name": "Milk",'
- ' "state_topic": "test_topic2",'
- ' "availability_topic": "availability_topic2" }'
- )
- async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data1)
- await hass.async_block_till_done()
- state = hass.states.get("binary_sensor.beer")
- assert state is not None
- assert state.name == "Beer"
- async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data2)
- await hass.async_block_till_done()
- state = hass.states.get("binary_sensor.beer")
- assert state is not None
- assert state.name == "Milk"
+ config1 = copy.deepcopy(DEFAULT_CONFIG[binary_sensor.DOMAIN])
+ config2 = copy.deepcopy(DEFAULT_CONFIG[binary_sensor.DOMAIN])
+ config1["name"] = "Beer"
+ config2["name"] = "Milk"
- state = hass.states.get("binary_sensor.milk")
- assert state is None
+ data1 = json.dumps(config1)
+ data2 = json.dumps(config2)
+ await help_test_discovery_update(
+ hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data1, data2
+ )
async def test_discovery_broken(hass, mqtt_mock, caplog):
"""Test handling of bad discovery message."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
data1 = '{ "name": "Beer",' ' "off_delay": -1 }'
data2 = '{ "name": "Milk",' ' "state_topic": "test_topic" }'
-
- async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("binary_sensor.beer")
- assert state is None
-
- async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("binary_sensor.milk")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("binary_sensor.beer")
- assert state is None
+ await help_test_discovery_broken(
+ hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data1, data2
+ )
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT binary sensor 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",
- "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",
- }
+ await help_test_entity_device_info_with_identifier(
+ hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
)
- async_fire_mqtt_message(hass, "homeassistant/binary_sensor/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()
-
- config = {
- "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",
- }
-
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data)
- await hass.async_block_till_done()
-
- device = registry.async_get_device({("mqtt", "helloworld")}, set())
- assert device is not None
- assert device.name == "Beer"
-
- config["device"]["name"] = "Milk"
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/binary_sensor/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"
+ await help_test_entity_device_info_update(
+ hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ )
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,
- binary_sensor.DOMAIN,
- {
- binary_sensor.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "state_topic": "test-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- },
- )
-
- state = hass.states.get("binary_sensor.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(
- "binary_sensor.beer", new_entity_id="binary_sensor.milk"
- )
- await hass.async_block_till_done()
-
- state = hass.states.get("binary_sensor.beer")
- assert state is None
-
- state = hass.states.get("binary_sensor.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")
+ 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)
diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py
index 29962287dd7..a6fb5f2cc66 100644
--- a/tests/components/mqtt/test_climate.py
+++ b/tests/components/mqtt/test_climate.py
@@ -2,12 +2,10 @@
import copy
import json
import unittest
-from unittest.mock import ANY
import pytest
import voluptuous as vol
-from homeassistant.components import mqtt
from homeassistant.components.climate import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP
from homeassistant.components.climate.const import (
DOMAIN as CLIMATE_DOMAIN,
@@ -25,16 +23,23 @@ from homeassistant.components.climate.const import (
SUPPORT_TARGET_TEMPERATURE,
SUPPORT_TARGET_TEMPERATURE_RANGE,
)
-from homeassistant.components.mqtt.discovery import async_start
from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE
-from tests.common import (
- MockConfigEntry,
- async_fire_mqtt_message,
- async_mock_mqtt_component,
- async_setup_component,
- mock_registry,
+from .common import (
+ help_test_discovery_broken,
+ help_test_discovery_removal,
+ help_test_discovery_update,
+ help_test_discovery_update_attr,
+ help_test_entity_device_info_update,
+ help_test_entity_device_info_with_identifier,
+ help_test_entity_id_update,
+ help_test_setting_attribute_via_mqtt_json_message,
+ help_test_unique_id,
+ help_test_update_with_json_attrs_bad_JSON,
+ help_test_update_with_json_attrs_not_dict,
)
+
+from tests.common import async_fire_mqtt_message, async_setup_component
from tests.components.climate import common
ENTITY_CLIMATE = "climate.test"
@@ -55,6 +60,32 @@ 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."""
@@ -768,316 +799,114 @@ 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."""
- assert await async_setup_component(
- hass,
- CLIMATE_DOMAIN,
- {
- CLIMATE_DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "power_state_topic": "test-topic",
- "power_command_topic": "test_topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_setting_attribute_via_mqtt_json_message(
+ hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
- state = hass.states.get("climate.test")
-
- assert state.attributes.get("val") == "100"
-
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- CLIMATE_DOMAIN,
- {
- CLIMATE_DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "power_state_topic": "test-topic",
- "power_command_topic": "test_topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_not_dict(
+ hass, mqtt_mock, caplog, CLIMATE_DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]')
- state = hass.states.get("climate.test")
-
- assert state.attributes.get("val") is None
- assert "JSON result was not a dictionary" in caplog.text
-
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- CLIMATE_DOMAIN,
- {
- CLIMATE_DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "power_state_topic": "test-topic",
- "power_command_topic": "test_topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_bad_JSON(
+ hass, mqtt_mock, caplog, CLIMATE_DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", "This is not JSON")
-
- state = hass.states.get("climate.test")
- assert state.attributes.get("val") is None
- assert "Erroneous JSON: This is not JSON" in caplog.text
-
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
- data1 = (
- '{ "name": "Beer",'
- ' "power_state_topic": "test-topic",'
- ' "power_command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic1" }'
+ 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
)
- data2 = (
- '{ "name": "Beer",'
- ' "power_state_topic": "test-topic",'
- ' "power_command_topic": "test_topic",'
- ' "json_attributes_topic": "attr-topic2" }'
- )
- async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data1)
- await hass.async_block_till_done()
- async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }')
- state = hass.states.get("climate.beer")
- assert state.attributes.get("val") == "100"
-
- # Change json_attributes_topic
- async_fire_mqtt_message(hass, "homeassistant/climate/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("climate.beer")
- 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("climate.beer")
- assert state.attributes.get("val") == "75"
async def test_unique_id(hass):
"""Test unique id option only creates one climate per unique_id."""
- await async_mock_mqtt_component(hass)
- assert await async_setup_component(
- hass,
- CLIMATE_DOMAIN,
- {
- CLIMATE_DOMAIN: [
- {
- "platform": "mqtt",
- "name": "Test 1",
- "power_state_topic": "test-topic",
- "power_command_topic": "test_topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- {
- "platform": "mqtt",
- "name": "Test 2",
- "power_state_topic": "test-topic",
- "power_command_topic": "test_topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- ]
- },
- )
- async_fire_mqtt_message(hass, "test-topic", "payload")
- assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1
+ config = {
+ CLIMATE_DOMAIN: [
+ {
+ "platform": "mqtt",
+ "name": "Test 1",
+ "power_state_topic": "test-topic",
+ "power_command_topic": "test_topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ {
+ "platform": "mqtt",
+ "name": "Test 2",
+ "power_state_topic": "test-topic",
+ "power_command_topic": "test_topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ ]
+ }
+ await help_test_unique_id(hass, CLIMATE_DOMAIN, config)
async def test_discovery_removal_climate(hass, mqtt_mock, caplog):
"""Test removal of discovered climate."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
- data = '{ "name": "Beer" }'
- async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data)
- await hass.async_block_till_done()
- state = hass.states.get("climate.beer")
- assert state is not None
- assert state.name == "Beer"
- async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", "")
- await hass.async_block_till_done()
- state = hass.states.get("climate.beer")
- assert state is None
+ data = json.dumps(DEFAULT_CONFIG[CLIMATE_DOMAIN])
+ await help_test_discovery_removal(hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data)
async def test_discovery_update_climate(hass, mqtt_mock, caplog):
"""Test update of discovered climate."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
data1 = '{ "name": "Beer" }'
data2 = '{ "name": "Milk" }'
- async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("climate.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("climate.beer")
- assert state is not None
- assert state.name == "Milk"
-
- state = hass.states.get("climate.milk")
- assert state is None
+ await help_test_discovery_update(
+ hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data1, data2
+ )
async def test_discovery_broken(hass, mqtt_mock, caplog):
"""Test handling of bad discovery message."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
data1 = '{ "name": "Beer",' ' "power_command_topic": "test_topic#" }'
data2 = '{ "name": "Milk", ' ' "power_command_topic": "test_topic" }'
-
- async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("climate.beer")
- assert state is None
-
- async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("climate.milk")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("climate.beer")
- assert state is None
+ await help_test_discovery_broken(
+ hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data1, data2
+ )
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT climate 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",
- "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, CLIMATE_DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
)
- async_fire_mqtt_message(hass, "homeassistant/climate/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()
-
- config = {
- "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",
- }
-
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data)
- await hass.async_block_till_done()
-
- device = registry.async_get_device({("mqtt", "helloworld")}, set())
- assert device is not None
- assert device.name == "Beer"
-
- config["device"]["name"] = "Milk"
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/climate/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"
+ await help_test_entity_device_info_update(
+ hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ )
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,
- CLIMATE_DOMAIN,
- {
- CLIMATE_DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "mode_state_topic": "test-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- },
- )
-
- state = hass.states.get("climate.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("climate.beer", new_entity_id="climate.milk")
- await hass.async_block_till_done()
-
- state = hass.states.get("climate.beer")
- assert state is None
-
- state = hass.states.get("climate.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")
+ config = {
+ CLIMATE_DOMAIN: [
+ {
+ "platform": "mqtt",
+ "name": "beer",
+ "mode_state_topic": "test-topic",
+ "availability_topic": "avty-topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ }
+ ]
+ }
+ await help_test_entity_id_update(hass, mqtt_mock, CLIMATE_DOMAIN, config)
async def test_precision_default(hass, mqtt_mock):
diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py
index 128c18de8df..78f7dc72a24 100644
--- a/tests/components/mqtt/test_cover.py
+++ b/tests/components/mqtt/test_cover.py
@@ -1,11 +1,7 @@
"""The tests for the MQTT cover platform."""
-import json
-from unittest.mock import ANY
-
-from homeassistant.components import cover, mqtt
+from homeassistant.components import cover
from homeassistant.components.cover import ATTR_POSITION, ATTR_TILT_POSITION
from homeassistant.components.mqtt.cover import MqttCover
-from homeassistant.components.mqtt.discovery import async_start
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_ENTITY_ID,
@@ -27,13 +23,47 @@ from homeassistant.const import (
)
from homeassistant.setup import async_setup_component
-from tests.common import (
- MockConfigEntry,
- async_fire_mqtt_message,
- async_mock_mqtt_component,
- mock_registry,
+from .common import (
+ help_test_discovery_broken,
+ help_test_discovery_removal,
+ help_test_discovery_update,
+ help_test_discovery_update_attr,
+ help_test_entity_device_info_update,
+ help_test_entity_device_info_with_identifier,
+ help_test_entity_id_update,
+ help_test_setting_attribute_via_mqtt_json_message,
+ help_test_unique_id,
+ help_test_update_with_json_attrs_bad_JSON,
+ help_test_update_with_json_attrs_not_dict,
)
+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",
+}
+
async def test_state_via_state_topic(hass, mqtt_mock):
"""Test the controlling state via topic."""
@@ -1693,309 +1723,113 @@ 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."""
- assert await async_setup_component(
- hass,
- cover.DOMAIN,
- {
- cover.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, cover.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
- state = hass.states.get("cover.test")
-
- assert state.attributes.get("val") == "100"
-
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- cover.DOMAIN,
- {
- cover.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_not_dict(
+ hass, mqtt_mock, caplog, cover.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]')
- state = hass.states.get("cover.test")
-
- assert state.attributes.get("val") is None
- assert "JSON result was not a dictionary" in caplog.text
-
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- cover.DOMAIN,
- {
- cover.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_bad_JSON(
+ hass, mqtt_mock, caplog, cover.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", "This is not JSON")
-
- state = hass.states.get("cover.test")
- assert state.attributes.get("val") is None
- assert "Erroneous JSON: This is not JSON" in caplog.text
-
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
data1 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "command_topic": "test_topic",'
' "json_attributes_topic": "attr-topic1" }'
)
data2 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "command_topic": "test_topic",'
' "json_attributes_topic": "attr-topic2" }'
)
- async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data1)
- await hass.async_block_till_done()
- async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }')
- state = hass.states.get("cover.beer")
- assert state.attributes.get("val") == "100"
- # Change json_attributes_topic
- async_fire_mqtt_message(hass, "homeassistant/cover/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("cover.beer")
- 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("cover.beer")
- assert state.attributes.get("val") == "75"
-
-
-async def test_discovery_removal_cover(hass, mqtt_mock, caplog):
- """Test removal of discovered cover."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
- data = '{ "name": "Beer",' ' "command_topic": "test_topic" }'
- async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data)
- await hass.async_block_till_done()
- state = hass.states.get("cover.beer")
- assert state is not None
- assert state.name == "Beer"
- async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", "")
- await hass.async_block_till_done()
- state = hass.states.get("cover.beer")
- assert state is None
-
-
-async def test_discovery_update_cover(hass, mqtt_mock, caplog):
- """Test update of discovered cover."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
- data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }'
- data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }'
- async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data1)
- await hass.async_block_till_done()
- state = hass.states.get("cover.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("cover.beer")
- assert state is not None
- assert state.name == "Milk"
-
- state = hass.states.get("cover.milk")
- assert state is None
-
-
-async def test_discovery_broken(hass, mqtt_mock, caplog):
- """Test handling of bad discovery message."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
- data1 = '{ "name": "Beer",' ' "command_topic": "test_topic#" }'
- data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }'
-
- async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("cover.beer")
- assert state is None
-
- async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("cover.milk")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("cover.beer")
- assert state is None
+ await help_test_discovery_update_attr(
+ hass, mqtt_mock, caplog, cover.DOMAIN, data1, data2
+ )
async def test_unique_id(hass):
"""Test unique_id option only creates one cover per id."""
- await async_mock_mqtt_component(hass)
- assert await async_setup_component(
- hass,
- cover.DOMAIN,
- {
- cover.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "Test 1",
- "state_topic": "test-topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- {
- "platform": "mqtt",
- "name": "Test 2",
- "state_topic": "test-topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- ]
- },
+ config = {
+ cover.DOMAIN: [
+ {
+ "platform": "mqtt",
+ "name": "Test 1",
+ "state_topic": "test-topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ {
+ "platform": "mqtt",
+ "name": "Test 2",
+ "state_topic": "test-topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ ]
+ }
+ await help_test_unique_id(hass, cover.DOMAIN, config)
+
+
+async def test_discovery_removal_cover(hass, mqtt_mock, caplog):
+ """Test removal of discovered cover."""
+ data = '{ "name": "test",' ' "command_topic": "test_topic" }'
+ await help_test_discovery_removal(hass, mqtt_mock, caplog, cover.DOMAIN, data)
+
+
+async def test_discovery_update_cover(hass, mqtt_mock, caplog):
+ """Test update of discovered cover."""
+ data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }'
+ data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }'
+ await help_test_discovery_update(
+ hass, mqtt_mock, caplog, cover.DOMAIN, data1, data2
)
- async_fire_mqtt_message(hass, "test-topic", "payload")
- assert len(hass.states.async_entity_ids(cover.DOMAIN)) == 1
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ data1 = '{ "name": "Beer",' ' "command_topic": "test_topic#" }'
+ data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }'
+ await help_test_discovery_broken(
+ hass, mqtt_mock, caplog, cover.DOMAIN, data1, data2
+ )
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT cover 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",
- "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",
- }
+ await help_test_entity_device_info_with_identifier(
+ hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
)
- async_fire_mqtt_message(hass, "homeassistant/cover/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()
-
- config = {
- "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",
- }
-
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data)
- await hass.async_block_till_done()
-
- device = registry.async_get_device({("mqtt", "helloworld")}, set())
- assert device is not None
- assert device.name == "Beer"
-
- config["device"]["name"] = "Milk"
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/cover/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"
+ await help_test_entity_device_info_update(
+ hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ )
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,
- cover.DOMAIN,
- {
- cover.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "state_topic": "test-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- },
- )
-
- state = hass.states.get("cover.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("cover.beer", new_entity_id="cover.milk")
- await hass.async_block_till_done()
-
- state = hass.states.get("cover.beer")
- assert state is None
-
- state = hass.states.get("cover.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")
+ 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)
diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py
index f4324bd8634..aa6d3efc828 100644
--- a/tests/components/mqtt/test_device_tracker.py
+++ b/tests/components/mqtt/test_device_tracker.py
@@ -2,11 +2,7 @@
from asynctest import patch
import pytest
-from homeassistant.components import device_tracker
-from homeassistant.components.device_tracker.const import (
- ENTITY_ID_FORMAT,
- SOURCE_TYPE_BLUETOOTH,
-)
+from homeassistant.components.device_tracker.const import DOMAIN, SOURCE_TYPE_BLUETOOTH
from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME
from homeassistant.setup import async_setup_component
@@ -35,14 +31,7 @@ async def test_ensure_device_tracker_platform_validation(hass):
dev_id = "paulus"
topic = "/location/paulus"
assert await async_setup_component(
- hass,
- device_tracker.DOMAIN,
- {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: "mqtt",
- "devices": {dev_id: topic},
- }
- },
+ hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: topic}}}
)
assert mock_sp.call_count == 1
@@ -50,15 +39,13 @@ async def test_ensure_device_tracker_platform_validation(hass):
async def test_new_message(hass, mock_device_tracker_conf):
"""Test new message."""
dev_id = "paulus"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
topic = "/location/paulus"
location = "work"
hass.config.components = set(["mqtt", "zone"])
assert await async_setup_component(
- hass,
- device_tracker.DOMAIN,
- {device_tracker.DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: topic}}},
+ hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: topic}}}
)
async_fire_mqtt_message(hass, topic, location)
await hass.async_block_till_done()
@@ -68,7 +55,7 @@ async def test_new_message(hass, mock_device_tracker_conf):
async def test_single_level_wildcard_topic(hass, mock_device_tracker_conf):
"""Test single level wildcard topic."""
dev_id = "paulus"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
subscription = "/location/+/paulus"
topic = "/location/room/paulus"
location = "work"
@@ -76,13 +63,8 @@ async def test_single_level_wildcard_topic(hass, mock_device_tracker_conf):
hass.config.components = set(["mqtt", "zone"])
assert await async_setup_component(
hass,
- device_tracker.DOMAIN,
- {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: "mqtt",
- "devices": {dev_id: subscription},
- }
- },
+ DOMAIN,
+ {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: subscription}}},
)
async_fire_mqtt_message(hass, topic, location)
await hass.async_block_till_done()
@@ -92,7 +74,7 @@ async def test_single_level_wildcard_topic(hass, mock_device_tracker_conf):
async def test_multi_level_wildcard_topic(hass, mock_device_tracker_conf):
"""Test multi level wildcard topic."""
dev_id = "paulus"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
subscription = "/location/#"
topic = "/location/room/paulus"
location = "work"
@@ -100,13 +82,8 @@ async def test_multi_level_wildcard_topic(hass, mock_device_tracker_conf):
hass.config.components = set(["mqtt", "zone"])
assert await async_setup_component(
hass,
- device_tracker.DOMAIN,
- {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: "mqtt",
- "devices": {dev_id: subscription},
- }
- },
+ DOMAIN,
+ {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: subscription}}},
)
async_fire_mqtt_message(hass, topic, location)
await hass.async_block_till_done()
@@ -116,7 +93,7 @@ async def test_multi_level_wildcard_topic(hass, mock_device_tracker_conf):
async def test_single_level_wildcard_topic_not_matching(hass, mock_device_tracker_conf):
"""Test not matching single level wildcard topic."""
dev_id = "paulus"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
subscription = "/location/+/paulus"
topic = "/location/paulus"
location = "work"
@@ -124,13 +101,8 @@ async def test_single_level_wildcard_topic_not_matching(hass, mock_device_tracke
hass.config.components = set(["mqtt", "zone"])
assert await async_setup_component(
hass,
- device_tracker.DOMAIN,
- {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: "mqtt",
- "devices": {dev_id: subscription},
- }
- },
+ DOMAIN,
+ {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: subscription}}},
)
async_fire_mqtt_message(hass, topic, location)
await hass.async_block_till_done()
@@ -140,7 +112,7 @@ async def test_single_level_wildcard_topic_not_matching(hass, mock_device_tracke
async def test_multi_level_wildcard_topic_not_matching(hass, mock_device_tracker_conf):
"""Test not matching multi level wildcard topic."""
dev_id = "paulus"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
subscription = "/location/#"
topic = "/somewhere/room/paulus"
location = "work"
@@ -148,13 +120,8 @@ async def test_multi_level_wildcard_topic_not_matching(hass, mock_device_tracker
hass.config.components = set(["mqtt", "zone"])
assert await async_setup_component(
hass,
- device_tracker.DOMAIN,
- {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: "mqtt",
- "devices": {dev_id: subscription},
- }
- },
+ DOMAIN,
+ {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: subscription}}},
)
async_fire_mqtt_message(hass, topic, location)
await hass.async_block_till_done()
@@ -166,7 +133,7 @@ async def test_matching_custom_payload_for_home_and_not_home(
):
"""Test custom payload_home sets state to home and custom payload_not_home sets state to not_home."""
dev_id = "paulus"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
topic = "/location/paulus"
payload_home = "present"
payload_not_home = "not present"
@@ -174,9 +141,9 @@ async def test_matching_custom_payload_for_home_and_not_home(
hass.config.components = set(["mqtt", "zone"])
assert await async_setup_component(
hass,
- device_tracker.DOMAIN,
+ DOMAIN,
{
- device_tracker.DOMAIN: {
+ DOMAIN: {
CONF_PLATFORM: "mqtt",
"devices": {dev_id: topic},
"payload_home": payload_home,
@@ -198,7 +165,7 @@ async def test_not_matching_custom_payload_for_home_and_not_home(
):
"""Test not matching payload does not set state to home or not_home."""
dev_id = "paulus"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
topic = "/location/paulus"
payload_home = "present"
payload_not_home = "not present"
@@ -207,9 +174,9 @@ async def test_not_matching_custom_payload_for_home_and_not_home(
hass.config.components = set(["mqtt", "zone"])
assert await async_setup_component(
hass,
- device_tracker.DOMAIN,
+ DOMAIN,
{
- device_tracker.DOMAIN: {
+ DOMAIN: {
CONF_PLATFORM: "mqtt",
"devices": {dev_id: topic},
"payload_home": payload_home,
@@ -226,7 +193,7 @@ async def test_not_matching_custom_payload_for_home_and_not_home(
async def test_matching_source_type(hass, mock_device_tracker_conf):
"""Test setting source type."""
dev_id = "paulus"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DOMAIN}.{dev_id}"
topic = "/location/paulus"
source_type = SOURCE_TYPE_BLUETOOTH
location = "work"
@@ -234,9 +201,9 @@ async def test_matching_source_type(hass, mock_device_tracker_conf):
hass.config.components = set(["mqtt", "zone"])
assert await async_setup_component(
hass,
- device_tracker.DOMAIN,
+ DOMAIN,
{
- device_tracker.DOMAIN: {
+ DOMAIN: {
CONF_PLATFORM: "mqtt",
"devices": {dev_id: topic},
"source_type": source_type,
diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py
index c3ba6eebadd..c7d1f636c02 100644
--- a/tests/components/mqtt/test_device_trigger.py
+++ b/tests/components/mqtt/test_device_trigger.py
@@ -468,7 +468,7 @@ async def test_if_fires_on_mqtt_message_after_update(
assert len(calls) == 2
-async def test_not_fires_on_mqtt_message_after_remove(
+async def test_not_fires_on_mqtt_message_after_remove_by_mqtt(
hass, device_reg, calls, mqtt_mock
):
"""Test triggers not firing after removal."""
@@ -532,6 +532,62 @@ async def test_not_fires_on_mqtt_message_after_remove(
assert len(calls) == 2
+async def test_not_fires_on_mqtt_message_after_remove_from_registry(
+ hass, device_reg, calls, mqtt_mock
+):
+ """Test triggers not firing after removal."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data={})
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data1 = (
+ '{ "automation_type":"trigger",'
+ ' "device":{"identifiers":["0AFFD2"]},'
+ ' "topic": "foobar/triggers/button1",'
+ ' "type": "button_short_press",'
+ ' "subtype": "button_1" }'
+ )
+ async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1)
+ await hass.async_block_till_done()
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": device_entry.id,
+ "discovery_id": "bla1",
+ "type": "button_short_press",
+ "subtype": "button_1",
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {"some": ("short_press")},
+ },
+ },
+ ]
+ },
+ )
+
+ # Fake short press.
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ # Remove the device
+ device_reg.async_remove_device(device_entry.id)
+ await hass.async_block_till_done()
+
+ async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+
async def test_attach_remove(hass, device_reg, mqtt_mock):
"""Test attach and removal of trigger."""
config_entry = MockConfigEntry(domain=DOMAIN, data={})
@@ -775,3 +831,42 @@ async def test_entity_device_info_update(hass, mqtt_mock):
device = registry.async_get_device({("mqtt", "helloworld")}, set())
assert device is not None
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."""
+ 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"
+
+ device_reg.async_remove_device(device_entry.id)
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ # Verify device registry entry is cleared
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, 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
+ )
diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py
index e09b4d786a6..4a28b95e32c 100644
--- a/tests/components/mqtt/test_discovery.py
+++ b/tests/components/mqtt/test_discovery.py
@@ -3,6 +3,8 @@ from pathlib import Path
import re
from unittest.mock import patch
+import pytest
+
from homeassistant.components import mqtt
from homeassistant.components.mqtt.abbreviations import (
ABBREVIATIONS,
@@ -11,7 +13,25 @@ from homeassistant.components.mqtt.abbreviations import (
from homeassistant.components.mqtt.discovery import ALREADY_DISCOVERED, async_start
from homeassistant.const import STATE_OFF, STATE_ON
-from tests.common import MockConfigEntry, async_fire_mqtt_message, mock_coro
+from tests.common import (
+ MockConfigEntry,
+ async_fire_mqtt_message,
+ mock_coro,
+ mock_device_registry,
+ mock_registry,
+)
+
+
+@pytest.fixture
+def device_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_device_registry(hass)
+
+
+@pytest.fixture
+def entity_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_registry(hass)
async def test_subscribing_config_topic(hass, mqtt_mock):
@@ -213,6 +233,114 @@ async def test_non_duplicate_discovery(hass, mqtt_mock, caplog):
assert "Component has already been discovered: binary_sensor bla" in caplog.text
+async def test_removal(hass, mqtt_mock, caplog):
+ """Test removal of component through empty discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, "homeassistant", {}, entry)
+
+ async_fire_mqtt_message(
+ hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }'
+ )
+ await hass.async_block_till_done()
+ state = hass.states.get("binary_sensor.beer")
+ assert state is not None
+
+ async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "")
+ await hass.async_block_till_done()
+ state = hass.states.get("binary_sensor.beer")
+ assert state is None
+
+
+async def test_rediscover(hass, mqtt_mock, caplog):
+ """Test rediscover of removed component."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, "homeassistant", {}, entry)
+
+ async_fire_mqtt_message(
+ hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }'
+ )
+ await hass.async_block_till_done()
+ state = hass.states.get("binary_sensor.beer")
+ assert state is not None
+
+ async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "")
+ await hass.async_block_till_done()
+ state = hass.states.get("binary_sensor.beer")
+ assert state is None
+
+ async_fire_mqtt_message(
+ hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }'
+ )
+ await hass.async_block_till_done()
+ state = hass.states.get("binary_sensor.beer")
+ assert state is not None
+
+
+async def test_duplicate_removal(hass, mqtt_mock, caplog):
+ """Test for a non duplicate component."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, "homeassistant", {}, entry)
+
+ async_fire_mqtt_message(
+ hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }'
+ )
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "")
+ await hass.async_block_till_done()
+ assert "Component has already been discovered: binary_sensor bla" in caplog.text
+ caplog.clear()
+ async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "")
+ await hass.async_block_till_done()
+
+ assert "Component has already been discovered: binary_sensor bla" not in caplog.text
+
+
+async def test_cleanup_device(hass, device_reg, entity_reg, mqtt_mock):
+ """Test discvered device is cleaned up when removed from registry."""
+ config_entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data = (
+ '{ "device":{"identifiers":["0AFFD2"]},'
+ ' "state_topic": "foobar/sensor",'
+ ' "unique_id": "unique" }'
+ )
+
+ async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data)
+ await hass.async_block_till_done()
+
+ # Verify device and registry entries are created
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+ assert device_entry is not None
+ entity_entry = entity_reg.async_get("sensor.mqtt_sensor")
+ assert entity_entry is not None
+
+ state = hass.states.get("sensor.mqtt_sensor")
+ assert state is not None
+
+ device_reg.async_remove_device(device_entry.id)
+ await hass.async_block_till_done()
+
+ # Verify device and registry entries are cleared
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+ assert device_entry is None
+ entity_entry = entity_reg.async_get("sensor.mqtt_sensor")
+ assert entity_entry is None
+
+ # Verify state is removed
+ state = hass.states.get("sensor.mqtt_sensor")
+ assert state is None
+
+ # Verify retained discovery topic has been cleared
+ mqtt_mock.async_publish.assert_called_once_with(
+ "homeassistant/sensor/bla/config", "", 0, True
+ )
+
+
async def test_discovery_expansion(hass, mqtt_mock, caplog):
"""Test expansion of abbreviated discovery payload."""
entry = MockConfigEntry(domain=mqtt.DOMAIN)
diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py
index 65e170fba91..512dddd4fc6 100644
--- a/tests/components/mqtt/test_fan.py
+++ b/tests/components/mqtt/test_fan.py
@@ -1,9 +1,5 @@
"""Test MQTT fans."""
-import json
-from unittest.mock import ANY
-
-from homeassistant.components import fan, mqtt
-from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.components import fan
from homeassistant.const import (
ATTR_ASSUMED_STATE,
STATE_OFF,
@@ -12,14 +8,48 @@ from homeassistant.const import (
)
from homeassistant.setup import async_setup_component
-from tests.common import (
- MockConfigEntry,
- async_fire_mqtt_message,
- async_mock_mqtt_component,
- mock_registry,
+from .common import (
+ help_test_discovery_broken,
+ help_test_discovery_removal,
+ help_test_discovery_update,
+ help_test_discovery_update_attr,
+ help_test_entity_device_info_update,
+ help_test_entity_device_info_with_identifier,
+ help_test_entity_id_update,
+ help_test_setting_attribute_via_mqtt_json_message,
+ help_test_unique_id,
+ help_test_update_with_json_attrs_bad_JSON,
+ help_test_update_with_json_attrs_not_dict,
)
+
+from tests.common import async_fire_mqtt_message
from tests.components.fan import common
+DEFAULT_CONFIG_ATTR = {
+ fan.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",
+}
+
async def test_fail_setup_if_no_command_topic(hass, mqtt_mock):
"""Test if command fails with command topic."""
@@ -438,314 +468,113 @@ async def test_custom_availability_payload(hass, mqtt_mock):
assert state.state is not STATE_UNAVAILABLE
-async def test_discovery_removal_fan(hass, mqtt_mock, caplog):
- """Test removal of discovered fan."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
- data = '{ "name": "Beer",' ' "command_topic": "test_topic" }'
- async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data)
- await hass.async_block_till_done()
- state = hass.states.get("fan.beer")
- assert state is not None
- assert state.name == "Beer"
- async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", "")
- await hass.async_block_till_done()
- state = hass.states.get("fan.beer")
- assert state is None
-
-
-async def test_discovery_update_fan(hass, mqtt_mock, caplog):
- """Test update of discovered fan."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
- data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }'
- data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }'
- async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("fan.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("fan.beer")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("fan.milk")
- assert state is None
-
-
-async def test_discovery_broken(hass, mqtt_mock, caplog):
- """Test handling of bad discovery message."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
- data1 = '{ "name": "Beer" }'
- data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }'
-
- async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("fan.beer")
- assert state is None
-
- async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("fan.milk")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("fan.beer")
- assert state is None
-
-
async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
- assert await async_setup_component(
- hass,
- fan.DOMAIN,
- {
- fan.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_setting_attribute_via_mqtt_json_message(
+ hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
- state = hass.states.get("fan.test")
-
- assert state.attributes.get("val") == "100"
-
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- fan.DOMAIN,
- {
- fan.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_not_dict(
+ hass, mqtt_mock, caplog, fan.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]')
- state = hass.states.get("fan.test")
-
- assert state.attributes.get("val") is None
- assert "JSON result was not a dictionary" in caplog.text
-
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- fan.DOMAIN,
- {
- fan.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_bad_JSON(
+ hass, mqtt_mock, caplog, fan.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", "This is not JSON")
-
- state = hass.states.get("fan.test")
- assert state.attributes.get("val") is None
- assert "Erroneous JSON: This is not JSON" in caplog.text
-
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
data1 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "command_topic": "test_topic",'
' "json_attributes_topic": "attr-topic1" }'
)
data2 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "command_topic": "test_topic",'
' "json_attributes_topic": "attr-topic2" }'
)
- async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data1)
- await hass.async_block_till_done()
- async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }')
- state = hass.states.get("fan.beer")
- assert state.attributes.get("val") == "100"
-
- # Change json_attributes_topic
- async_fire_mqtt_message(hass, "homeassistant/fan/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("fan.beer")
- 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("fan.beer")
- assert state.attributes.get("val") == "75"
+ await help_test_discovery_update_attr(
+ hass, mqtt_mock, caplog, fan.DOMAIN, data1, data2
+ )
async def test_unique_id(hass):
"""Test unique_id option only creates one fan per id."""
- await async_mock_mqtt_component(hass)
- assert await async_setup_component(
- hass,
- fan.DOMAIN,
- {
- fan.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "Test 1",
- "state_topic": "test-topic",
- "command_topic": "test-topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- {
- "platform": "mqtt",
- "name": "Test 2",
- "state_topic": "test-topic",
- "command_topic": "test-topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- ]
- },
- )
+ config = {
+ fan.DOMAIN: [
+ {
+ "platform": "mqtt",
+ "name": "Test 1",
+ "state_topic": "test-topic",
+ "command_topic": "test_topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ {
+ "platform": "mqtt",
+ "name": "Test 2",
+ "state_topic": "test-topic",
+ "command_topic": "test_topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ ]
+ }
+ await help_test_unique_id(hass, fan.DOMAIN, config)
- async_fire_mqtt_message(hass, "test-topic", "payload")
- assert len(hass.states.async_entity_ids(fan.DOMAIN)) == 1
+async def test_discovery_removal_fan(hass, mqtt_mock, caplog):
+ """Test removal of discovered fan."""
+ data = '{ "name": "test",' ' "command_topic": "test_topic" }'
+ await help_test_discovery_removal(hass, mqtt_mock, caplog, fan.DOMAIN, data)
+
+
+async def test_discovery_update_fan(hass, mqtt_mock, caplog):
+ """Test update of discovered fan."""
+ data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }'
+ data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }'
+ await help_test_discovery_update(hass, mqtt_mock, caplog, fan.DOMAIN, data1, data2)
+
+
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ data1 = '{ "name": "Beer" }'
+ data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }'
+ await help_test_discovery_broken(hass, mqtt_mock, caplog, fan.DOMAIN, data1, data2)
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT fan 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",
- "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",
- }
+ await help_test_entity_device_info_with_identifier(
+ hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
)
- async_fire_mqtt_message(hass, "homeassistant/fan/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()
-
- config = {
- "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",
- }
-
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data)
- await hass.async_block_till_done()
-
- device = registry.async_get_device({("mqtt", "helloworld")}, set())
- assert device is not None
- assert device.name == "Beer"
-
- config["device"]["name"] = "Milk"
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/fan/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"
+ await help_test_entity_device_info_update(
+ hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ )
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,
- fan.DOMAIN,
- {
- fan.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "state_topic": "test-topic",
- "command_topic": "command-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- },
- )
-
- state = hass.states.get("fan.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("fan.beer", new_entity_id="fan.milk")
- await hass.async_block_till_done()
-
- state = hass.states.get("fan.beer")
- assert state is None
-
- state = hass.states.get("fan.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")
+ 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)
diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py
index dc79cb8a2e7..7d06c62b915 100644
--- a/tests/components/mqtt/test_init.py
+++ b/tests/components/mqtt/test_init.py
@@ -7,7 +7,8 @@ from unittest import mock
import pytest
import voluptuous as vol
-from homeassistant.components import mqtt
+from homeassistant.components import mqtt, websocket_api
+from homeassistant.components.mqtt.discovery import async_start
from homeassistant.const import (
ATTR_DOMAIN,
ATTR_SERVICE,
@@ -16,6 +17,7 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
@@ -27,11 +29,25 @@ from tests.common import (
fire_mqtt_message,
get_test_home_assistant,
mock_coro,
+ mock_device_registry,
mock_mqtt_component,
+ mock_registry,
threadsafe_coroutine_factory,
)
+@pytest.fixture
+def device_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_device_registry(hass)
+
+
+@pytest.fixture
+def entity_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_registry(hass)
+
+
@pytest.fixture
def mock_MQTT():
"""Make sure connection is established."""
@@ -828,3 +844,93 @@ async def test_dump_service(hass):
assert len(writes) == 2
assert writes[0][1][0] == "bla/1,test1\n"
assert writes[1][1][0] == "bla/2,test2\n"
+
+
+async def test_mqtt_ws_remove_discovered_device(
+ hass, device_reg, entity_reg, hass_ws_client, mqtt_mock
+):
+ """Test MQTT websocket device removal."""
+ config_entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data = (
+ '{ "device":{"identifiers":["0AFFD2"]},'
+ ' "state_topic": "foobar/sensor",'
+ ' "unique_id": "unique" }'
+ )
+
+ async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data)
+ await hass.async_block_till_done()
+
+ # Verify device entry is created
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+ assert device_entry is not None
+
+ client = await hass_ws_client(hass)
+ await client.send_json(
+ {"id": 5, "type": "mqtt/device/remove", "device_id": device_entry.id}
+ )
+ response = await client.receive_json()
+ assert response["success"]
+
+ # Verify device entry is cleared
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+ assert device_entry is None
+
+
+async def test_mqtt_ws_remove_discovered_device_twice(
+ hass, device_reg, hass_ws_client, mqtt_mock
+):
+ """Test MQTT websocket device removal."""
+ config_entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ config_entry.add_to_hass(hass)
+ await async_start(hass, "homeassistant", {}, config_entry)
+
+ data = (
+ '{ "device":{"identifiers":["0AFFD2"]},'
+ ' "state_topic": "foobar/sensor",'
+ ' "unique_id": "unique" }'
+ )
+
+ async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data)
+ await hass.async_block_till_done()
+
+ device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
+ assert device_entry is not None
+
+ client = await hass_ws_client(hass)
+ await client.send_json(
+ {"id": 5, "type": "mqtt/device/remove", "device_id": device_entry.id}
+ )
+ response = await client.receive_json()
+ assert response["success"]
+
+ await client.send_json(
+ {"id": 6, "type": "mqtt/device/remove", "device_id": device_entry.id}
+ )
+ response = await client.receive_json()
+ assert not response["success"]
+ assert response["error"]["code"] == websocket_api.const.ERR_NOT_FOUND
+
+
+async def test_mqtt_ws_remove_non_mqtt_device(
+ hass, device_reg, hass_ws_client, mqtt_mock
+):
+ """Test MQTT websocket device removal of device belonging to other domain."""
+ config_entry = MockConfigEntry(domain="test")
+ config_entry.add_to_hass(hass)
+
+ device_entry = device_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
+ )
+ assert device_entry is not None
+
+ client = await hass_ws_client(hass)
+ await client.send_json(
+ {"id": 5, "type": "mqtt/device/remove", "device_id": device_entry.id}
+ )
+ response = await client.receive_json()
+ assert not response["success"]
+ assert response["error"]["code"] == websocket_api.const.ERR_NOT_FOUND
diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py
index f6128decc1a..c3500e6ac6a 100644
--- a/tests/components/mqtt/test_legacy_vacuum.py
+++ b/tests/components/mqtt/test_legacy_vacuum.py
@@ -2,9 +2,8 @@
from copy import deepcopy
import json
-from homeassistant.components import mqtt, vacuum
+from homeassistant.components import vacuum
from homeassistant.components.mqtt import CONF_COMMAND_TOPIC
-from homeassistant.components.mqtt.discovery import async_start
from homeassistant.components.mqtt.vacuum import schema_legacy as mqttvacuum
from homeassistant.components.mqtt.vacuum.schema import services_to_strings
from homeassistant.components.mqtt.vacuum.schema_legacy import (
@@ -26,11 +25,21 @@ from homeassistant.const import (
)
from homeassistant.setup import async_setup_component
-from tests.common import (
- MockConfigEntry,
- async_fire_mqtt_message,
- async_mock_mqtt_component,
+from .common import (
+ help_test_discovery_broken,
+ help_test_discovery_removal,
+ help_test_discovery_update,
+ help_test_discovery_update_attr,
+ help_test_entity_device_info_update,
+ help_test_entity_device_info_with_identifier,
+ help_test_entity_id_update,
+ help_test_setting_attribute_via_mqtt_json_message,
+ help_test_unique_id,
+ help_test_update_with_json_attrs_bad_JSON,
+ help_test_update_with_json_attrs_not_dict,
)
+
+from tests.common import async_fire_mqtt_message
from tests.components.vacuum import common
DEFAULT_CONFIG = {
@@ -54,6 +63,29 @@ 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",
+}
+
async def test_default_supported_features(hass, mqtt_mock):
"""Test that the correct supported features."""
@@ -514,273 +546,116 @@ async def test_custom_availability_payload(hass, mqtt_mock):
assert state.state == STATE_UNAVAILABLE
-async def test_discovery_removal_vacuum(hass, mqtt_mock):
- """Test removal of discovered vacuum."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
- data = '{ "name": "Beer",' ' "command_topic": "test_topic" }'
-
- async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data)
- await hass.async_block_till_done()
-
- state = hass.states.get("vacuum.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", "")
- await hass.async_block_till_done()
-
- state = hass.states.get("vacuum.beer")
- assert state is None
-
-
-async def test_discovery_broken(hass, mqtt_mock, caplog):
- """Test handling of bad discovery message."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
- data1 = '{ "name": "Beer",' ' "command_topic": "test_topic#" }'
- data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }'
-
- async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("vacuum.beer")
- assert state is None
-
- async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("vacuum.milk")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("vacuum.beer")
- assert state is None
-
-
-async def test_discovery_update_vacuum(hass, mqtt_mock):
- """Test update of discovered vacuum."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
- data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }'
- data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }'
-
- async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("vacuum.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("vacuum.beer")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("vacuum.milk")
- assert state is None
-
-
async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
- assert await async_setup_component(
- hass,
- vacuum.DOMAIN,
- {
- vacuum.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_setting_attribute_via_mqtt_json_message(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
- state = hass.states.get("vacuum.test")
-
- assert state.attributes.get("val") == "100"
-
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- vacuum.DOMAIN,
- {
- vacuum.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_not_dict(
+ hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]')
- state = hass.states.get("vacuum.test")
- assert state.attributes.get("val") is None
- assert "JSON result was not a dictionary" in caplog.text
-
-
-async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog):
+async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- vacuum.DOMAIN,
- {
- vacuum.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_bad_JSON(
+ hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", "This is not JSON")
-
- state = hass.states.get("vacuum.test")
- assert state.attributes.get("val") is None
- assert "Erroneous JSON: This is not JSON" in caplog.text
-
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
data1 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "command_topic": "test_topic",'
' "json_attributes_topic": "attr-topic1" }'
)
data2 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "command_topic": "test_topic",'
' "json_attributes_topic": "attr-topic2" }'
)
- async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data1)
- await hass.async_block_till_done()
- async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }')
- state = hass.states.get("vacuum.beer")
- assert state.attributes.get("val") == "100"
-
- # Change json_attributes_topic
- async_fire_mqtt_message(hass, "homeassistant/vacuum/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("vacuum.beer")
- 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("vacuum.beer")
- assert state.attributes.get("val") == "75"
+ await help_test_discovery_update_attr(
+ hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2
+ )
async def test_unique_id(hass, mqtt_mock):
"""Test unique id option only creates one vacuum per unique_id."""
- await async_mock_mqtt_component(hass)
- assert await async_setup_component(
- hass,
- vacuum.DOMAIN,
- {
- vacuum.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "Test 1",
- "command_topic": "command-topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- {
- "platform": "mqtt",
- "name": "Test 2",
- "command_topic": "command-topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- ]
- },
+ config = {
+ vacuum.DOMAIN: [
+ {
+ "platform": "mqtt",
+ "name": "Test 1",
+ "command_topic": "test_topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ {
+ "platform": "mqtt",
+ "name": "Test 2",
+ "command_topic": "test_topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ ]
+ }
+ await help_test_unique_id(hass, vacuum.DOMAIN, config)
+
+
+async def test_discovery_removal_vacuum(hass, mqtt_mock, caplog):
+ """Test removal of discovered vacuum."""
+ data = json.dumps(DEFAULT_CONFIG_ATTR[vacuum.DOMAIN])
+ await help_test_discovery_removal(hass, mqtt_mock, caplog, vacuum.DOMAIN, data)
+
+
+async def test_discovery_update_vacuum(hass, mqtt_mock, caplog):
+ """Test update of discovered vacuum."""
+ data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }'
+ data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }'
+ await help_test_discovery_update(
+ hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2
)
- async_fire_mqtt_message(hass, "test-topic", "payload")
- assert len(hass.states.async_entity_ids()) == 1
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ data1 = '{ "name": "Beer",' ' "command_topic": "test_topic#" }'
+ data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }'
+ await help_test_discovery_broken(
+ hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2
+ )
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT vacuum 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",
- "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",
- }
+ await help_test_entity_device_info_with_identifier(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
)
- async_fire_mqtt_message(hass, "homeassistant/vacuum/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, vacuum.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ )
+
+async def test_entity_id_update(hass, mqtt_mock):
+ """Test MQTT subscriptions are managed when entity_id is updated."""
config = {
- "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",
+ 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",
+ }
+ ]
}
-
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data)
- await hass.async_block_till_done()
-
- device = registry.async_get_device({("mqtt", "helloworld")}, set())
- assert device is not None
- assert device.name == "Beer"
-
- config["device"]["name"] = "Milk"
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/vacuum/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"
+ await help_test_entity_id_update(hass, mqtt_mock, vacuum.DOMAIN, config)
diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py
index 43ccaf6dea5..f2bde3d3b43 100644
--- a/tests/components/mqtt/test_light.py
+++ b/tests/components/mqtt/test_light.py
@@ -153,9 +153,8 @@ light:
payload_off: "off"
"""
-import json
from unittest import mock
-from unittest.mock import ANY, patch
+from unittest.mock import patch
from homeassistant.components import light, mqtt
from homeassistant.components.mqtt.discovery import async_start
@@ -168,16 +167,53 @@ from homeassistant.const import (
import homeassistant.core as ha
from homeassistant.setup import async_setup_component
+from .common import (
+ help_test_discovery_broken,
+ help_test_discovery_removal,
+ help_test_discovery_update,
+ help_test_discovery_update_attr,
+ help_test_entity_device_info_update,
+ help_test_entity_device_info_with_identifier,
+ help_test_entity_id_update,
+ help_test_setting_attribute_via_mqtt_json_message,
+ 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,
- async_mock_mqtt_component,
mock_coro,
- mock_registry,
)
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",
+}
+
async def test_fail_setup_if_no_command_topic(hass, mqtt_mock):
"""Test if command fails with command topic."""
@@ -1063,156 +1099,73 @@ async def test_custom_availability_payload(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."""
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_setting_attribute_via_mqtt_json_message(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
- state = hass.states.get("light.test")
-
- assert state.attributes.get("val") == "100"
-
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_not_dict(
+ hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]')
- state = hass.states.get("light.test")
-
- assert state.attributes.get("val") is None
- assert "JSON result was not a dictionary" in caplog.text
-
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_bad_JSON(
+ hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", "This is not JSON")
-
- state = hass.states.get("light.test")
- assert state.attributes.get("val") is None
- assert "Erroneous JSON: This is not JSON" in caplog.text
-
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
data1 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "command_topic": "test_topic",'
' "json_attributes_topic": "attr-topic1" }'
)
data2 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "command_topic": "test_topic",'
' "json_attributes_topic": "attr-topic2" }'
)
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1)
- await hass.async_block_till_done()
- async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }')
- state = hass.states.get("light.beer")
- assert state.attributes.get("val") == "100"
-
- # Change json_attributes_topic
- async_fire_mqtt_message(hass, "homeassistant/light/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("light.beer")
- 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("light.beer")
- assert state.attributes.get("val") == "75"
+ await help_test_discovery_update_attr(
+ hass, mqtt_mock, caplog, light.DOMAIN, data1, data2
+ )
async def test_unique_id(hass):
"""Test unique id option only creates one light per unique_id."""
- await async_mock_mqtt_component(hass)
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "Test 1",
- "state_topic": "test-topic",
- "command_topic": "test_topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- {
- "platform": "mqtt",
- "name": "Test 2",
- "state_topic": "test-topic",
- "command_topic": "test_topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- ]
- },
- )
- async_fire_mqtt_message(hass, "test-topic", "payload")
- assert len(hass.states.async_entity_ids(light.DOMAIN)) == 1
+ config = {
+ light.DOMAIN: [
+ {
+ "platform": "mqtt",
+ "name": "Test 1",
+ "state_topic": "test-topic",
+ "command_topic": "test_topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ {
+ "platform": "mqtt",
+ "name": "Test 2",
+ "state_topic": "test-topic",
+ "command_topic": "test_topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ ]
+ }
+ await help_test_unique_id(hass, light.DOMAIN, config)
async def test_discovery_removal_light(hass, mqtt_mock, caplog):
"""Test removal of discovered light."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
data = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "state_topic": "test_topic",'
' "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_fire_mqtt_message(hass, "homeassistant/light/bla/config", "")
- await hass.async_block_till_done()
-
- state = hass.states.get("light.beer")
- assert state is None
+ await help_test_discovery_removal(hass, mqtt_mock, caplog, light.DOMAIN, data)
async def test_discovery_deprecated(hass, mqtt_mock, caplog):
@@ -1231,9 +1184,6 @@ async def test_discovery_deprecated(hass, mqtt_mock, caplog):
async def test_discovery_update_light(hass, mqtt_mock, caplog):
"""Test update of discovered light."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
data1 = (
'{ "name": "Beer",'
' "state_topic": "test_topic",'
@@ -1244,166 +1194,50 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog):
' "state_topic": "test_topic",'
' "command_topic": "test_topic" }'
)
-
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("light.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("light.beer")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("light.milk")
- assert state is None
+ await help_test_discovery_update(
+ hass, mqtt_mock, caplog, light.DOMAIN, data1, data2
+ )
async def test_discovery_broken(hass, mqtt_mock, caplog):
"""Test handling of bad discovery message."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
data1 = '{ "name": "Beer" }'
data2 = (
'{ "name": "Milk",'
' "state_topic": "test_topic",'
' "command_topic": "test_topic" }'
)
-
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("light.beer")
- assert state is None
-
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("light.milk")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("light.beer")
- assert state is None
+ await help_test_discovery_broken(
+ hass, mqtt_mock, caplog, light.DOMAIN, data1, data2
+ )
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT light 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",
- "state_topic": "test-topic",
- "command_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, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
)
- async_fire_mqtt_message(hass, "homeassistant/light/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()
-
- config = {
- "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",
- }
-
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data)
- await hass.async_block_till_done()
-
- device = registry.async_get_device({("mqtt", "helloworld")}, set())
- assert device is not None
- assert device.name == "Beer"
-
- config["device"]["name"] = "Milk"
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/light/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"
+ await help_test_entity_device_info_update(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ )
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,
- light.DOMAIN,
- {
- light.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "state_topic": "test-topic",
- "command_topic": "command-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- },
- )
-
- state = hass.states.get("light.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("light.beer", new_entity_id="light.milk")
- await hass.async_block_till_done()
-
- state = hass.states.get("light.beer")
- assert state is None
-
- state = hass.states.get("light.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")
+ 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)
diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py
index 355451f6469..71ced8f1db2 100644
--- a/tests/components/mqtt/test_light_json.py
+++ b/tests/components/mqtt/test_light_json.py
@@ -89,7 +89,7 @@ light:
"""
import json
from unittest import mock
-from unittest.mock import ANY, patch
+from unittest.mock import patch
from homeassistant.components import light, mqtt
from homeassistant.components.mqtt.discovery import async_start
@@ -103,15 +103,50 @@ from homeassistant.const import (
import homeassistant.core as ha
from homeassistant.setup import async_setup_component
-from tests.common import (
- MockConfigEntry,
- async_fire_mqtt_message,
- async_mock_mqtt_component,
- mock_coro,
- mock_registry,
+from .common import (
+ help_test_discovery_broken,
+ help_test_discovery_removal,
+ help_test_discovery_update,
+ help_test_discovery_update_attr,
+ help_test_entity_device_info_update,
+ help_test_entity_device_info_with_identifier,
+ help_test_entity_id_update,
+ help_test_setting_attribute_via_mqtt_json_message,
+ 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.components.light import common
+DEFAULT_CONFIG_ATTR = {
+ 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."""
@@ -913,154 +948,73 @@ async def test_custom_availability_payload(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."""
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: {
- "platform": "mqtt",
- "schema": "json",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_setting_attribute_via_mqtt_json_message(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
- state = hass.states.get("light.test")
-
- assert state.attributes.get("val") == "100"
-
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: {
- "platform": "mqtt",
- "schema": "json",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_not_dict(
+ hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]')
- state = hass.states.get("light.test")
-
- assert state.attributes.get("val") is None
- assert "JSON result was not a dictionary" in caplog.text
-
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: {
- "platform": "mqtt",
- "schema": "json",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_bad_JSON(
+ hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", "This is not JSON")
-
- state = hass.states.get("light.test")
- assert state.attributes.get("val") is None
- assert "Erroneous JSON: This is not JSON" in caplog.text
-
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
data1 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "schema": "json",'
' "command_topic": "test_topic",'
' "json_attributes_topic": "attr-topic1" }'
)
data2 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "schema": "json",'
' "command_topic": "test_topic",'
' "json_attributes_topic": "attr-topic2" }'
)
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1)
- await hass.async_block_till_done()
- async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }')
- state = hass.states.get("light.beer")
- assert state.attributes.get("val") == "100"
-
- # Change json_attributes_topic
- async_fire_mqtt_message(hass, "homeassistant/light/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("light.beer")
- 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("light.beer")
- assert state.attributes.get("val") == "75"
+ await help_test_discovery_update_attr(
+ hass, mqtt_mock, caplog, light.DOMAIN, data1, data2
+ )
async def test_unique_id(hass):
"""Test unique id option only creates one light per unique_id."""
- await async_mock_mqtt_component(hass)
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "Test 1",
- "schema": "json",
- "state_topic": "test-topic",
- "command_topic": "test_topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- {
- "platform": "mqtt",
- "name": "Test 2",
- "schema": "json",
- "state_topic": "test-topic",
- "command_topic": "test_topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- ]
- },
- )
- async_fire_mqtt_message(hass, "test-topic", "payload")
- assert len(hass.states.async_entity_ids(light.DOMAIN)) == 1
+ config = {
+ light.DOMAIN: [
+ {
+ "platform": "mqtt",
+ "name": "Test 1",
+ "schema": "json",
+ "state_topic": "test-topic",
+ "command_topic": "test_topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ {
+ "platform": "mqtt",
+ "name": "Test 2",
+ "schema": "json",
+ "state_topic": "test-topic",
+ "command_topic": "test_topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ ]
+ }
+ await help_test_unique_id(hass, light.DOMAIN, config)
async def test_discovery_removal(hass, mqtt_mock, caplog):
"""Test removal of discovered mqtt_json lights."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {"mqtt": {}}, entry)
- data = '{ "name": "Beer",' ' "schema": "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_fire_mqtt_message(hass, "homeassistant/light/bla/config", "")
- await hass.async_block_till_done()
- state = hass.states.get("light.beer")
- assert state is None
+ data = '{ "name": "test",' ' "schema": "json",' ' "command_topic": "test_topic" }'
+ await help_test_discovery_removal(hass, mqtt_mock, caplog, light.DOMAIN, data)
async def test_discovery_deprecated(hass, mqtt_mock, caplog):
@@ -1081,9 +1035,6 @@ async def test_discovery_deprecated(hass, mqtt_mock, caplog):
async def test_discovery_update_light(hass, mqtt_mock, caplog):
"""Test update of discovered light."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
data1 = (
'{ "name": "Beer",'
' "schema": "json",'
@@ -1096,29 +1047,13 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog):
' "state_topic": "test_topic",'
' "command_topic": "test_topic" }'
)
-
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("light.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("light.beer")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("light.milk")
- assert state is None
+ await help_test_discovery_update(
+ hass, mqtt_mock, caplog, light.DOMAIN, data1, data2
+ )
async def test_discovery_broken(hass, mqtt_mock, caplog):
"""Test handling of bad discovery message."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
data1 = '{ "name": "Beer" }'
data2 = (
'{ "name": "Milk",'
@@ -1126,140 +1061,38 @@ async def test_discovery_broken(hass, mqtt_mock, caplog):
' "state_topic": "test_topic",'
' "command_topic": "test_topic" }'
)
-
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("light.beer")
- assert state is None
-
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("light.milk")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("light.beer")
- assert state is None
+ await help_test_discovery_broken(
+ hass, mqtt_mock, caplog, light.DOMAIN, data1, data2
+ )
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT light 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",
- "schema": "json",
- "state_topic": "test-topic",
- "command_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, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
)
- async_fire_mqtt_message(hass, "homeassistant/light/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()
-
- config = {
- "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",
- }
-
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data)
- await hass.async_block_till_done()
-
- device = registry.async_get_device({("mqtt", "helloworld")}, set())
- assert device is not None
- assert device.name == "Beer"
-
- config["device"]["name"] = "Milk"
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/light/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"
+ await help_test_entity_device_info_update(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ )
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,
- light.DOMAIN,
- {
- light.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "schema": "json",
- "state_topic": "test-topic",
- "command_topic": "command-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- },
- )
-
- state = hass.states.get("light.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("light.beer", new_entity_id="light.milk")
- await hass.async_block_till_done()
-
- state = hass.states.get("light.beer")
- assert state is None
-
- state = hass.states.get("light.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")
+ 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)
diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py
index 1d109af5930..9d4d3fcba25 100644
--- a/tests/components/mqtt/test_light_template.py
+++ b/tests/components/mqtt/test_light_template.py
@@ -26,8 +26,7 @@ If your light doesn't support white value feature, omit `white_value_template`.
If your light doesn't support RGB feature, omit `(red|green|blue)_template`.
"""
-import json
-from unittest.mock import ANY, patch
+from unittest.mock import patch
from homeassistant.components import light, mqtt
from homeassistant.components.mqtt.discovery import async_start
@@ -40,15 +39,58 @@ from homeassistant.const import (
import homeassistant.core as ha
from homeassistant.setup import async_setup_component
+from .common import (
+ help_test_discovery_broken,
+ help_test_discovery_removal,
+ help_test_discovery_update,
+ help_test_discovery_update_attr,
+ help_test_entity_device_info_update,
+ help_test_entity_device_info_with_identifier,
+ help_test_entity_id_update,
+ help_test_setting_attribute_via_mqtt_json_message,
+ 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,
- async_mock_mqtt_component,
mock_coro,
- mock_registry,
)
+DEFAULT_CONFIG_ATTR = {
+ light.DOMAIN: {
+ "platform": "mqtt",
+ "schema": "template",
+ "name": "test",
+ "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."""
@@ -510,84 +552,29 @@ async def test_custom_availability_payload(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."""
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: {
- "platform": "mqtt",
- "schema": "template",
- "name": "test",
- "command_topic": "test-topic",
- "command_on_template": "on,{{ transition }}",
- "command_off_template": "off,{{ transition|d }}",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_setting_attribute_via_mqtt_json_message(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
- state = hass.states.get("light.test")
-
- assert state.attributes.get("val") == "100"
-
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: {
- "platform": "mqtt",
- "schema": "template",
- "name": "test",
- "command_topic": "test-topic",
- "command_on_template": "on,{{ transition }}",
- "command_off_template": "off,{{ transition|d }}",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_not_dict(
+ hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]')
- state = hass.states.get("light.test")
-
- assert state.attributes.get("val") is None
- assert "JSON result was not a dictionary" in caplog.text
-
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: {
- "platform": "mqtt",
- "schema": "template",
- "name": "test",
- "command_topic": "test-topic",
- "command_on_template": "on,{{ transition }}",
- "command_off_template": "off,{{ transition|d }}",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_bad_JSON(
+ hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", "This is not JSON")
-
- state = hass.states.get("light.test")
- assert state.attributes.get("val") is None
- assert "Erroneous JSON: This is not JSON" in caplog.text
-
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
data1 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "schema": "template",'
' "command_topic": "test_topic",'
' "command_on_template": "on",'
@@ -595,87 +582,55 @@ async def test_discovery_update_attr(hass, mqtt_mock, caplog):
' "json_attributes_topic": "attr-topic1" }'
)
data2 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "schema": "template",'
' "command_topic": "test_topic",'
' "command_on_template": "on",'
' "command_off_template": "off",'
' "json_attributes_topic": "attr-topic2" }'
)
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1)
- await hass.async_block_till_done()
- async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }')
- state = hass.states.get("light.beer")
- assert state.attributes.get("val") == "100"
-
- # Change json_attributes_topic
- async_fire_mqtt_message(hass, "homeassistant/light/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("light.beer")
- 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("light.beer")
- assert state.attributes.get("val") == "75"
+ await help_test_discovery_update_attr(
+ hass, mqtt_mock, caplog, light.DOMAIN, data1, data2
+ )
async def test_unique_id(hass):
"""Test unique id option only creates one light per unique_id."""
- await async_mock_mqtt_component(hass)
- assert await async_setup_component(
- hass,
- light.DOMAIN,
- {
- light.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "Test 1",
- "schema": "template",
- "state_topic": "test-topic",
- "command_topic": "test_topic",
- "command_on_template": "on,{{ transition }}",
- "command_off_template": "off,{{ transition|d }}",
- "unique_id": "TOTALLY_UNIQUE",
- },
- {
- "platform": "mqtt",
- "name": "Test 2",
- "schema": "template",
- "state_topic": "test-topic",
- "command_topic": "test_topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- ]
- },
- )
- async_fire_mqtt_message(hass, "test-topic", "payload")
- assert len(hass.states.async_entity_ids(light.DOMAIN)) == 1
+ config = {
+ light.DOMAIN: [
+ {
+ "platform": "mqtt",
+ "name": "Test 1",
+ "schema": "template",
+ "state_topic": "test-topic",
+ "command_topic": "test_topic",
+ "command_on_template": "on,{{ transition }}",
+ "command_off_template": "off,{{ transition|d }}",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ {
+ "platform": "mqtt",
+ "name": "Test 2",
+ "schema": "template",
+ "state_topic": "test-topic",
+ "command_topic": "test_topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ ]
+ }
+ await help_test_unique_id(hass, light.DOMAIN, config)
async def test_discovery_removal(hass, mqtt_mock, caplog):
"""Test removal of discovered mqtt_json lights."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {"mqtt": {}}, entry)
data = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "schema": "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_fire_mqtt_message(hass, "homeassistant/light/bla/config", "")
- await hass.async_block_till_done()
- state = hass.states.get("light.beer")
- assert state is None
+ await help_test_discovery_removal(hass, mqtt_mock, caplog, light.DOMAIN, data)
async def test_discovery_deprecated(hass, mqtt_mock, caplog):
@@ -698,9 +653,6 @@ async def test_discovery_deprecated(hass, mqtt_mock, caplog):
async def test_discovery_update_light(hass, mqtt_mock, caplog):
"""Test update of discovered light."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
data1 = (
'{ "name": "Beer",'
' "schema": "template",'
@@ -717,29 +669,13 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog):
' "command_on_template": "on",'
' "command_off_template": "off"}'
)
-
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("light.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("light.beer")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("light.milk")
- assert state is None
+ await help_test_discovery_update(
+ hass, mqtt_mock, caplog, light.DOMAIN, data1, data2
+ )
async def test_discovery_broken(hass, mqtt_mock, caplog):
"""Test handling of bad discovery message."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
data1 = '{ "name": "Beer" }'
data2 = (
'{ "name": "Milk",'
@@ -749,146 +685,40 @@ async def test_discovery_broken(hass, mqtt_mock, caplog):
' "command_on_template": "on",'
' "command_off_template": "off"}'
)
-
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("light.beer")
- assert state is None
-
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("light.milk")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("light.beer")
- assert state is None
+ await help_test_discovery_broken(
+ hass, mqtt_mock, caplog, light.DOMAIN, data1, data2
+ )
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT light 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",
- "schema": "template",
- "state_topic": "test-topic",
- "command_topic": "test-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",
- }
+ await help_test_entity_device_info_with_identifier(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
)
- async_fire_mqtt_message(hass, "homeassistant/light/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()
-
- config = {
- "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",
- }
-
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data)
- await hass.async_block_till_done()
-
- device = registry.async_get_device({("mqtt", "helloworld")}, set())
- assert device is not None
- assert device.name == "Beer"
-
- config["device"]["name"] = "Milk"
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/light/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"
+ await help_test_entity_device_info_update(
+ hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ )
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,
- light.DOMAIN,
- {
- 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",
- }
- ]
- },
- )
-
- state = hass.states.get("light.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("light.beer", new_entity_id="light.milk")
- await hass.async_block_till_done()
-
- state = hass.states.get("light.beer")
- assert state is None
-
- state = hass.states.get("light.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")
+ 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)
diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py
index 9b89fa7159d..d636eb1534d 100644
--- a/tests/components/mqtt/test_lock.py
+++ b/tests/components/mqtt/test_lock.py
@@ -1,9 +1,5 @@
"""The tests for the MQTT lock platform."""
-import json
-from unittest.mock import ANY
-
-from homeassistant.components import lock, mqtt
-from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.components import lock
from homeassistant.const import (
ATTR_ASSUMED_STATE,
STATE_LOCKED,
@@ -12,14 +8,48 @@ from homeassistant.const import (
)
from homeassistant.setup import async_setup_component
-from tests.common import (
- MockConfigEntry,
- async_fire_mqtt_message,
- async_mock_mqtt_component,
- mock_registry,
+from .common import (
+ help_test_discovery_broken,
+ help_test_discovery_removal,
+ help_test_discovery_update,
+ help_test_discovery_update_attr,
+ help_test_entity_device_info_update,
+ help_test_entity_device_info_with_identifier,
+ help_test_entity_id_update,
+ help_test_setting_attribute_via_mqtt_json_message,
+ help_test_unique_id,
+ help_test_update_with_json_attrs_bad_JSON,
+ help_test_update_with_json_attrs_not_dict,
)
+
+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",
+}
+
async def test_controlling_state_via_topic(hass, mqtt_mock):
"""Test the controlling state via topic."""
@@ -309,177 +339,73 @@ async def test_custom_availability_payload(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."""
- assert await async_setup_component(
- hass,
- lock.DOMAIN,
- {
- lock.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_setting_attribute_via_mqtt_json_message(
+ hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
- state = hass.states.get("lock.test")
-
- assert state.attributes.get("val") == "100"
-
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- lock.DOMAIN,
- {
- lock.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_not_dict(
+ hass, mqtt_mock, caplog, lock.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]')
- state = hass.states.get("lock.test")
-
- assert state.attributes.get("val") is None
- assert "JSON result was not a dictionary" in caplog.text
-
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- lock.DOMAIN,
- {
- lock.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_bad_JSON(
+ hass, mqtt_mock, caplog, lock.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", "This is not JSON")
-
- state = hass.states.get("lock.test")
- assert state.attributes.get("val") is None
- assert "Erroneous JSON: This is not JSON" in caplog.text
-
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
data1 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "command_topic": "test_topic",'
' "json_attributes_topic": "attr-topic1" }'
)
data2 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "command_topic": "test_topic",'
' "json_attributes_topic": "attr-topic2" }'
)
- async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data1)
- await hass.async_block_till_done()
- async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }')
- state = hass.states.get("lock.beer")
- assert state.attributes.get("val") == "100"
-
- # Change json_attributes_topic
- async_fire_mqtt_message(hass, "homeassistant/lock/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("lock.beer")
- 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("lock.beer")
- assert state.attributes.get("val") == "75"
+ await help_test_discovery_update_attr(
+ hass, mqtt_mock, caplog, lock.DOMAIN, data1, data2
+ )
async def test_unique_id(hass):
- """Test unique id option only creates one light per unique_id."""
- await async_mock_mqtt_component(hass)
- assert await async_setup_component(
- hass,
- lock.DOMAIN,
- {
- lock.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "Test 1",
- "state_topic": "test-topic",
- "command_topic": "test_topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- {
- "platform": "mqtt",
- "name": "Test 2",
- "state_topic": "test-topic",
- "command_topic": "test_topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- ]
- },
- )
- async_fire_mqtt_message(hass, "test-topic", "payload")
- assert len(hass.states.async_entity_ids(lock.DOMAIN)) == 1
+ """Test unique id option only creates one lock per unique_id."""
+ config = {
+ lock.DOMAIN: [
+ {
+ "platform": "mqtt",
+ "name": "Test 1",
+ "state_topic": "test-topic",
+ "command_topic": "test_topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ {
+ "platform": "mqtt",
+ "name": "Test 2",
+ "state_topic": "test-topic",
+ "command_topic": "test_topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ ]
+ }
+ await help_test_unique_id(hass, lock.DOMAIN, config)
async def test_discovery_removal_lock(hass, mqtt_mock, caplog):
"""Test removal of discovered lock."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
- data = '{ "name": "Beer",' ' "command_topic": "test_topic" }'
- async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data)
- await hass.async_block_till_done()
- state = hass.states.get("lock.beer")
- assert state is not None
- assert state.name == "Beer"
- async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", "")
- await hass.async_block_till_done()
- state = hass.states.get("lock.beer")
- assert state is None
-
-
-async def test_discovery_broken(hass, mqtt_mock, caplog):
- """Test handling of bad discovery message."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
- data1 = '{ "name": "Beer" }'
- data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }'
-
- async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("lock.beer")
- assert state is None
-
- async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("lock.milk")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("lock.beer")
- assert state is None
+ data = '{ "name": "test",' ' "command_topic": "test_topic" }'
+ await help_test_discovery_removal(hass, mqtt_mock, caplog, lock.DOMAIN, data)
async def test_discovery_update_lock(hass, mqtt_mock, caplog):
"""Test update of discovered lock."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
data1 = (
'{ "name": "Beer",'
' "state_topic": "test_topic",'
@@ -492,135 +418,42 @@ async def test_discovery_update_lock(hass, mqtt_mock, caplog):
' "command_topic": "command_topic",'
' "availability_topic": "availability_topic2" }'
)
- async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data1)
- await hass.async_block_till_done()
- state = hass.states.get("lock.beer")
- assert state is not None
- assert state.name == "Beer"
- async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data2)
- await hass.async_block_till_done()
- state = hass.states.get("lock.beer")
- assert state is not None
- assert state.name == "Milk"
+ await help_test_discovery_update(hass, mqtt_mock, caplog, lock.DOMAIN, data1, data2)
- state = hass.states.get("lock.milk")
- assert state is None
+
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ data1 = '{ "name": "Beer" }'
+ data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }'
+ await help_test_discovery_broken(hass, mqtt_mock, caplog, lock.DOMAIN, data1, data2)
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT lock 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",
- "state_topic": "test-topic",
- "command_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, lock.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
)
- async_fire_mqtt_message(hass, "homeassistant/lock/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()
-
- config = {
- "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",
- }
-
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data)
- await hass.async_block_till_done()
-
- device = registry.async_get_device({("mqtt", "helloworld")}, set())
- assert device is not None
- assert device.name == "Beer"
-
- config["device"]["name"] = "Milk"
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/lock/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"
+ await help_test_entity_device_info_update(
+ hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ )
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,
- lock.DOMAIN,
- {
- lock.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "state_topic": "test-topic",
- "command_topic": "test-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- },
- )
-
- state = hass.states.get("lock.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("lock.beer", new_entity_id="lock.milk")
- await hass.async_block_till_done()
-
- state = hass.states.get("lock.beer")
- assert state is None
-
- state = hass.states.get("lock.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")
+ 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)
diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py
index 66f8996bc2e..0cf24894bcb 100644
--- a/tests/components/mqtt/test_sensor.py
+++ b/tests/components/mqtt/test_sensor.py
@@ -1,7 +1,7 @@
"""The tests for the MQTT sensor platform."""
from datetime import datetime, timedelta
import json
-from unittest.mock import ANY, patch
+from unittest.mock import patch
from homeassistant.components import mqtt
from homeassistant.components.mqtt.discovery import async_start
@@ -11,14 +11,50 @@ import homeassistant.core as ha
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
+from .common import (
+ help_test_discovery_broken,
+ help_test_discovery_removal,
+ help_test_discovery_update,
+ help_test_discovery_update_attr,
+ help_test_entity_device_info_update,
+ help_test_entity_device_info_with_identifier,
+ help_test_entity_id_update,
+ help_test_setting_attribute_via_mqtt_json_message,
+ 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_fire_time_changed,
- async_mock_mqtt_component,
- mock_registry,
)
+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",
+}
+
async def test_setting_sensor_value_via_mqtt_message(hass, mqtt_mock):
"""Test the setting of the value via MQTT."""
@@ -395,24 +431,18 @@ 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."""
- assert await async_setup_component(
- hass,
- sensor.DOMAIN,
- {
- sensor.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "state_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ 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
)
- async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
- state = hass.states.get("sensor.test")
-
- assert state.attributes.get("val") == "100"
-
async def test_setting_attribute_with_template(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
@@ -441,290 +471,108 @@ async def test_setting_attribute_with_template(hass, mqtt_mock):
async def test_update_with_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",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_not_dict(
+ hass, mqtt_mock, caplog, sensor.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-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_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",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_bad_JSON(
+ hass, mqtt_mock, caplog, sensor.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-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_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
data1 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "state_topic": "test_topic",'
' "json_attributes_topic": "attr-topic1" }'
)
data2 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "state_topic": "test_topic",'
' "json_attributes_topic": "attr-topic2" }'
)
- async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data1)
- await hass.async_block_till_done()
- async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }')
- state = hass.states.get("sensor.beer")
- assert state.attributes.get("val") == "100"
-
- # Change json_attributes_topic
- async_fire_mqtt_message(hass, "homeassistant/sensor/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("sensor.beer")
- 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("sensor.beer")
- assert state.attributes.get("val") == "75"
+ await help_test_discovery_update_attr(
+ hass, mqtt_mock, caplog, sensor.DOMAIN, data1, data2
+ )
async def test_unique_id(hass):
"""Test unique id option only creates one sensor per unique_id."""
- await async_mock_mqtt_component(hass)
- assert await async_setup_component(
- hass,
- sensor.DOMAIN,
- {
- sensor.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "Test 1",
- "state_topic": "test-topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- {
- "platform": "mqtt",
- "name": "Test 2",
- "state_topic": "test-topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- ]
- },
- )
-
- async_fire_mqtt_message(hass, "test-topic", "payload")
-
- assert len(hass.states.async_all()) == 1
+ config = {
+ sensor.DOMAIN: [
+ {
+ "platform": "mqtt",
+ "name": "Test 1",
+ "state_topic": "test-topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ {
+ "platform": "mqtt",
+ "name": "Test 2",
+ "state_topic": "test-topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ ]
+ }
+ await help_test_unique_id(hass, sensor.DOMAIN, config)
async def test_discovery_removal_sensor(hass, mqtt_mock, caplog):
"""Test removal of discovered sensor."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
- data = '{ "name": "Beer",' ' "state_topic": "test_topic" }'
- async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data)
- await hass.async_block_till_done()
- state = hass.states.get("sensor.beer")
- assert state is not None
- assert state.name == "Beer"
- async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", "")
- await hass.async_block_till_done()
- state = hass.states.get("sensor.beer")
- assert state is None
+ data = '{ "name": "test",' ' "state_topic": "test_topic" }'
+ await help_test_discovery_removal(hass, mqtt_mock, caplog, sensor.DOMAIN, data)
async def test_discovery_update_sensor(hass, mqtt_mock, caplog):
"""Test update of discovered sensor."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
data1 = '{ "name": "Beer",' ' "state_topic": "test_topic" }'
data2 = '{ "name": "Milk",' ' "state_topic": "test_topic" }'
- async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("sensor.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("sensor.beer")
- assert state is not None
- assert state.name == "Milk"
-
- state = hass.states.get("sensor.milk")
- assert state is None
+ await help_test_discovery_update(
+ hass, mqtt_mock, caplog, sensor.DOMAIN, data1, data2
+ )
async def test_discovery_broken(hass, mqtt_mock, caplog):
"""Test handling of bad discovery message."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
data1 = '{ "name": "Beer",' ' "state_topic": "test_topic#" }'
data2 = '{ "name": "Milk",' ' "state_topic": "test_topic" }'
-
- async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("sensor.beer")
- assert state is None
-
- async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("sensor.milk")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("sensor.beer")
- assert state is None
+ await help_test_discovery_broken(
+ hass, mqtt_mock, caplog, sensor.DOMAIN, data1, data2
+ )
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT sensor 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",
- "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",
- }
+ await help_test_entity_device_info_with_identifier(
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
)
- async_fire_mqtt_message(hass, "homeassistant/sensor/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()
-
- config = {
- "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",
- }
-
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data)
- await hass.async_block_till_done()
-
- device = registry.async_get_device({("mqtt", "helloworld")}, set())
- assert device is not None
- assert device.name == "Beer"
-
- config["device"]["name"] = "Milk"
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/sensor/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"
+ await help_test_entity_device_info_update(
+ hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ )
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,
- sensor.DOMAIN,
- {
- sensor.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "state_topic": "test-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- },
- )
-
- state = hass.states.get("sensor.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("sensor.beer", new_entity_id="sensor.milk")
- await hass.async_block_till_done()
-
- state = hass.states.get("sensor.beer")
- assert state is None
-
- state = hass.states.get("sensor.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")
+ 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)
async def test_entity_device_info_with_hub(hass, mqtt_mock):
diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py
index d7e45004f13..52c101d138c 100644
--- a/tests/components/mqtt/test_state_vacuum.py
+++ b/tests/components/mqtt/test_state_vacuum.py
@@ -2,9 +2,8 @@
from copy import deepcopy
import json
-from homeassistant.components import mqtt, vacuum
+from homeassistant.components import vacuum
from homeassistant.components.mqtt import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC
-from homeassistant.components.mqtt.discovery import async_start
from homeassistant.components.mqtt.vacuum import CONF_SCHEMA, schema_state as mqttvacuum
from homeassistant.components.mqtt.vacuum.schema import services_to_strings
from homeassistant.components.mqtt.vacuum.schema_state import SERVICE_TO_STRING
@@ -32,11 +31,21 @@ from homeassistant.const import (
)
from homeassistant.setup import async_setup_component
-from tests.common import (
- MockConfigEntry,
- async_fire_mqtt_message,
- async_mock_mqtt_component,
+from .common import (
+ help_test_discovery_broken,
+ help_test_discovery_removal,
+ help_test_discovery_update,
+ help_test_discovery_update_attr,
+ help_test_entity_device_info_update,
+ help_test_entity_device_info_with_identifier,
+ help_test_entity_id_update,
+ help_test_setting_attribute_via_mqtt_json_message,
+ help_test_unique_id,
+ help_test_update_with_json_attrs_bad_JSON,
+ help_test_update_with_json_attrs_not_dict,
)
+
+from tests.common import async_fire_mqtt_message
from tests.components.vacuum import common
COMMAND_TOPIC = "vacuum/command"
@@ -54,6 +63,31 @@ 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",
+}
+
async def test_default_supported_features(hass, mqtt_mock):
"""Test that the correct supported features."""
@@ -340,273 +374,120 @@ async def test_custom_availability_payload(hass, mqtt_mock):
assert state.state == STATE_UNAVAILABLE
-async def test_discovery_removal_vacuum(hass, mqtt_mock):
- """Test removal of discovered vacuum."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
- data = '{ "name": "Beer",' ' "command_topic": "test_topic"}'
-
- async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data)
- await hass.async_block_till_done()
-
- state = hass.states.get("vacuum.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", "")
- await hass.async_block_till_done()
-
- state = hass.states.get("vacuum.beer")
- assert state is None
-
-
-async def test_discovery_broken(hass, mqtt_mock, caplog):
- """Test handling of bad discovery message."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
- data1 = '{ "name": "Beer",' ' "command_topic": "test_topic#"}'
- data2 = '{ "name": "Milk",' ' "command_topic": "test_topic"}'
-
- async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("vacuum.beer")
- assert state is None
-
- async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("vacuum.milk")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("vacuum.beer")
- assert state is None
-
-
-async def test_discovery_update_vacuum(hass, mqtt_mock):
- """Test update of discovered vacuum."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
- data1 = '{ "name": "Beer",' ' "command_topic": "test_topic"}'
- data2 = '{ "name": "Milk",' ' "command_topic": "test_topic"}'
-
- async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("vacuum.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("vacuum.beer")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("vacuum.milk")
- assert state is None
-
-
async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
"""Test the setting of attribute via MQTT with JSON payload."""
- assert await async_setup_component(
- hass,
- vacuum.DOMAIN,
- {
- vacuum.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_setting_attribute_via_mqtt_json_message(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
- state = hass.states.get("vacuum.test")
-
- assert state.attributes.get("val") == "100"
-
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- vacuum.DOMAIN,
- {
- vacuum.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_not_dict(
+ hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]')
- state = hass.states.get("vacuum.test")
-
- assert state.attributes.get("val") is None
- assert "JSON result was not a dictionary" in caplog.text
-
async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- vacuum.DOMAIN,
- {
- vacuum.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_bad_JSON(
+ hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", "This is not JSON")
-
- state = hass.states.get("vacuum.test")
- assert state.attributes.get("val") is None
- assert "Erroneous JSON: This is not JSON" in caplog.text
-
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
data1 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
+ ' "schema": "state",'
' "command_topic": "test_topic",'
' "json_attributes_topic": "attr-topic1" }'
)
data2 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
+ ' "schema": "state",'
' "command_topic": "test_topic",'
' "json_attributes_topic": "attr-topic2" }'
)
- async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data1)
- await hass.async_block_till_done()
- async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }')
- state = hass.states.get("vacuum.beer")
- assert state.attributes.get("val") == "100"
-
- # Change json_attributes_topic
- async_fire_mqtt_message(hass, "homeassistant/vacuum/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("vacuum.beer")
- 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("vacuum.beer")
- assert state.attributes.get("val") == "75"
+ await help_test_discovery_update_attr(
+ hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2
+ )
async def test_unique_id(hass, mqtt_mock):
"""Test unique id option only creates one vacuum per unique_id."""
- await async_mock_mqtt_component(hass)
- assert await async_setup_component(
- hass,
- vacuum.DOMAIN,
- {
- vacuum.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "Test 1",
- "command_topic": "command-topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- {
- "platform": "mqtt",
- "name": "Test 2",
- "command_topic": "command-topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- ]
- },
+ config = {
+ vacuum.DOMAIN: [
+ {
+ "platform": "mqtt",
+ "schema": "state",
+ "name": "Test 1",
+ "command_topic": "command-topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ {
+ "platform": "mqtt",
+ "schema": "state",
+ "name": "Test 2",
+ "command_topic": "command-topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ ]
+ }
+ await help_test_unique_id(hass, vacuum.DOMAIN, config)
+
+
+async def test_discovery_removal_vacuum(hass, mqtt_mock, caplog):
+ """Test removal of discovered vacuum."""
+ data = '{ "schema": "state", "name": "test",' ' "command_topic": "test_topic"}'
+ await help_test_discovery_removal(hass, mqtt_mock, caplog, vacuum.DOMAIN, data)
+
+
+async def test_discovery_update_vacuum(hass, mqtt_mock, caplog):
+ """Test update of discovered vacuum."""
+ data1 = '{ "schema": "state", "name": "Beer",' ' "command_topic": "test_topic"}'
+ data2 = '{ "schema": "state", "name": "Milk",' ' "command_topic": "test_topic"}'
+ await help_test_discovery_update(
+ hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2
)
- async_fire_mqtt_message(hass, "test-topic", "payload")
- assert len(hass.states.async_entity_ids()) == 1
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ data1 = '{ "schema": "state", "name": "Beer",' ' "command_topic": "test_topic#"}'
+ data2 = '{ "schema": "state", "name": "Milk",' ' "command_topic": "test_topic"}'
+ await help_test_discovery_broken(
+ hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2
+ )
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT vacuum 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",
- "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",
- }
+ await help_test_entity_device_info_with_identifier(
+ hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
)
- async_fire_mqtt_message(hass, "homeassistant/vacuum/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, vacuum.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ )
+
+async def test_entity_id_update(hass, mqtt_mock):
+ """Test MQTT subscriptions are managed when entity_id is updated."""
config = {
- "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",
+ vacuum.DOMAIN: [
+ {
+ "platform": "mqtt",
+ "schema": "state",
+ "name": "beer",
+ "state_topic": "test-topic",
+ "command_topic": "command-topic",
+ "availability_topic": "avty-topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ }
+ ]
}
-
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data)
- await hass.async_block_till_done()
-
- device = registry.async_get_device({("mqtt", "helloworld")}, set())
- assert device is not None
- assert device.name == "Beer"
-
- config["device"]["name"] = "Milk"
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/vacuum/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"
+ await help_test_entity_id_update(hass, mqtt_mock, vacuum.DOMAIN, config)
diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py
index 8412edffbbe..983d91f08a2 100644
--- a/tests/components/mqtt/test_switch.py
+++ b/tests/components/mqtt/test_switch.py
@@ -1,12 +1,8 @@
"""The tests for the MQTT switch platform."""
-import json
-from unittest.mock import ANY
-
from asynctest import patch
import pytest
-from homeassistant.components import mqtt, switch
-from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.components import switch
from homeassistant.const import (
ATTR_ASSUMED_STATE,
STATE_OFF,
@@ -16,15 +12,48 @@ from homeassistant.const import (
import homeassistant.core as ha
from homeassistant.setup import async_setup_component
-from tests.common import (
- MockConfigEntry,
- async_fire_mqtt_message,
- async_mock_mqtt_component,
- mock_coro,
- mock_registry,
+from .common import (
+ help_test_discovery_broken,
+ help_test_discovery_removal,
+ help_test_discovery_update,
+ help_test_discovery_update_attr,
+ help_test_entity_device_info_update,
+ help_test_entity_device_info_with_identifier,
+ help_test_entity_id_update,
+ help_test_setting_attribute_via_mqtt_json_message,
+ help_test_unique_id,
+ help_test_update_with_json_attrs_bad_JSON,
+ help_test_update_with_json_attrs_not_dict,
)
+
+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",
+}
+
@pytest.fixture
def mock_publish(hass):
@@ -265,165 +294,77 @@ 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."""
- assert await async_setup_component(
- hass,
- switch.DOMAIN,
- {
- switch.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_setting_attribute_via_mqtt_json_message(
+ hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
- state = hass.states.get("switch.test")
-
- assert state.attributes.get("val") == "100"
-
async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- switch.DOMAIN,
- {
- switch.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_not_dict(
+ hass, mqtt_mock, caplog, switch.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]')
- state = hass.states.get("switch.test")
-
- assert state.attributes.get("val") is None
- assert "JSON result was not a dictionary" in caplog.text
-
async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
"""Test attributes get extracted from a JSON result."""
- assert await async_setup_component(
- hass,
- switch.DOMAIN,
- {
- switch.DOMAIN: {
- "platform": "mqtt",
- "name": "test",
- "command_topic": "test-topic",
- "json_attributes_topic": "attr-topic",
- }
- },
+ await help_test_update_with_json_attrs_bad_JSON(
+ hass, mqtt_mock, caplog, switch.DOMAIN, DEFAULT_CONFIG_ATTR
)
- async_fire_mqtt_message(hass, "attr-topic", "This is not JSON")
-
- state = hass.states.get("switch.test")
- assert state.attributes.get("val") is None
- assert "Erroneous JSON: This is not JSON" in caplog.text
-
async def test_discovery_update_attr(hass, mqtt_mock, caplog):
"""Test update of discovered MQTTAttributes."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
data1 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "command_topic": "test_topic",'
' "json_attributes_topic": "attr-topic1" }'
)
data2 = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "command_topic": "test_topic",'
' "json_attributes_topic": "attr-topic2" }'
)
- async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data1)
- await hass.async_block_till_done()
- async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }')
- state = hass.states.get("switch.beer")
- assert state.attributes.get("val") == "100"
-
- # Change json_attributes_topic
- async_fire_mqtt_message(hass, "homeassistant/switch/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("switch.beer")
- 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("switch.beer")
- assert state.attributes.get("val") == "75"
+ await help_test_discovery_update_attr(
+ hass, mqtt_mock, caplog, switch.DOMAIN, data1, data2
+ )
async def test_unique_id(hass):
"""Test unique id option only creates one switch per unique_id."""
- await async_mock_mqtt_component(hass)
- assert await async_setup_component(
- hass,
- switch.DOMAIN,
- {
- switch.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "Test 1",
- "state_topic": "test-topic",
- "command_topic": "command-topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- {
- "platform": "mqtt",
- "name": "Test 2",
- "state_topic": "test-topic",
- "command_topic": "command-topic",
- "unique_id": "TOTALLY_UNIQUE",
- },
- ]
- },
- )
-
- async_fire_mqtt_message(hass, "test-topic", "payload")
-
- assert len(hass.states.async_entity_ids()) == 1
+ config = {
+ switch.DOMAIN: [
+ {
+ "platform": "mqtt",
+ "name": "Test 1",
+ "state_topic": "test-topic",
+ "command_topic": "command-topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ {
+ "platform": "mqtt",
+ "name": "Test 2",
+ "state_topic": "test-topic",
+ "command_topic": "command-topic",
+ "unique_id": "TOTALLY_UNIQUE",
+ },
+ ]
+ }
+ await help_test_unique_id(hass, switch.DOMAIN, config)
async def test_discovery_removal_switch(hass, mqtt_mock, caplog):
"""Test removal of discovered switch."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
data = (
- '{ "name": "Beer",'
+ '{ "name": "test",'
' "state_topic": "test_topic",'
' "command_topic": "test_topic" }'
)
-
- async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data)
- await hass.async_block_till_done()
-
- state = hass.states.get("switch.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", "")
- await hass.async_block_till_done()
-
- state = hass.states.get("switch.beer")
- assert state is None
+ await help_test_discovery_removal(hass, mqtt_mock, caplog, switch.DOMAIN, data)
async def test_discovery_update_switch(hass, mqtt_mock, caplog):
"""Test update of discovered switch."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
data1 = (
'{ "name": "Beer",'
' "state_topic": "test_topic",'
@@ -434,166 +375,50 @@ async def test_discovery_update_switch(hass, mqtt_mock, caplog):
' "state_topic": "test_topic",'
' "command_topic": "test_topic" }'
)
-
- async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("switch.beer")
- assert state is not None
- assert state.name == "Beer"
-
- async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("switch.beer")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("switch.milk")
- assert state is None
+ await help_test_discovery_update(
+ hass, mqtt_mock, caplog, switch.DOMAIN, data1, data2
+ )
async def test_discovery_broken(hass, mqtt_mock, caplog):
"""Test handling of bad discovery message."""
- entry = MockConfigEntry(domain=mqtt.DOMAIN)
- await async_start(hass, "homeassistant", {}, entry)
-
data1 = '{ "name": "Beer" }'
data2 = (
'{ "name": "Milk",'
' "state_topic": "test_topic",'
' "command_topic": "test_topic" }'
)
-
- async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data1)
- await hass.async_block_till_done()
-
- state = hass.states.get("switch.beer")
- assert state is None
-
- async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data2)
- await hass.async_block_till_done()
-
- state = hass.states.get("switch.milk")
- assert state is not None
- assert state.name == "Milk"
- state = hass.states.get("switch.beer")
- assert state is None
+ await help_test_discovery_broken(
+ hass, mqtt_mock, caplog, switch.DOMAIN, data1, data2
+ )
async def test_entity_device_info_with_identifier(hass, mqtt_mock):
"""Test MQTT switch 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",
- "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",
- }
+ await help_test_entity_device_info_with_identifier(
+ hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
)
- async_fire_mqtt_message(hass, "homeassistant/switch/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()
-
- config = {
- "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",
- }
-
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data)
- await hass.async_block_till_done()
-
- device = registry.async_get_device({("mqtt", "helloworld")}, set())
- assert device is not None
- assert device.name == "Beer"
-
- config["device"]["name"] = "Milk"
- data = json.dumps(config)
- async_fire_mqtt_message(hass, "homeassistant/switch/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"
+ await help_test_entity_device_info_update(
+ hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO
+ )
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,
- switch.DOMAIN,
- {
- switch.DOMAIN: [
- {
- "platform": "mqtt",
- "name": "beer",
- "state_topic": "test-topic",
- "command_topic": "command-topic",
- "availability_topic": "avty-topic",
- "unique_id": "TOTALLY_UNIQUE",
- }
- ]
- },
- )
-
- state = hass.states.get("switch.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("switch.beer", new_entity_id="switch.milk")
- await hass.async_block_till_done()
-
- state = hass.states.get("switch.beer")
- assert state is None
-
- state = hass.states.get("switch.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")
+ 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)
diff --git a/tests/components/mqtt_json/test_device_tracker.py b/tests/components/mqtt_json/test_device_tracker.py
index 5af196c5bf2..9efff135fe2 100644
--- a/tests/components/mqtt_json/test_device_tracker.py
+++ b/tests/components/mqtt_json/test_device_tracker.py
@@ -8,7 +8,6 @@ import pytest
from homeassistant.components.device_tracker.legacy import (
DOMAIN as DT_DOMAIN,
- ENTITY_ID_FORMAT,
YAML_DEVICES,
)
from homeassistant.const import CONF_PLATFORM
@@ -161,7 +160,7 @@ async def test_multi_level_wildcard_topic(hass):
async def test_single_level_wildcard_topic_not_matching(hass):
"""Test not matching single level wildcard topic."""
dev_id = "zanzito"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DT_DOMAIN}.{dev_id}"
subscription = "location/+/zanzito"
topic = "location/zanzito"
location = json.dumps(LOCATION_MESSAGE)
@@ -179,7 +178,7 @@ async def test_single_level_wildcard_topic_not_matching(hass):
async def test_multi_level_wildcard_topic_not_matching(hass):
"""Test not matching multi level wildcard topic."""
dev_id = "zanzito"
- entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ entity_id = f"{DT_DOMAIN}.{dev_id}"
subscription = "location/#"
topic = "somewhere/zanzito"
location = json.dumps(LOCATION_MESSAGE)
diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py
index d76578d277c..c9a663991cb 100644
--- a/tests/components/netatmo/test_config_flow.py
+++ b/tests/components/netatmo/test_config_flow.py
@@ -1,4 +1,6 @@
"""Test the Netatmo config flow."""
+from asynctest import patch
+
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.netatmo import config_flow
from homeassistant.components.netatmo.const import (
@@ -88,6 +90,10 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock):
},
)
- result = await hass.config_entries.flow.async_configure(result["flow_id"])
+ with patch(
+ "homeassistant.components.netatmo.async_setup_entry", return_value=True
+ ) as mock_setup:
+ await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+ assert len(mock_setup.mock_calls) == 1
diff --git a/tests/components/notion/test_config_flow.py b/tests/components/notion/test_config_flow.py
index f7651a570cf..60ca4c07fb5 100644
--- a/tests/components/notion/test_config_flow.py
+++ b/tests/components/notion/test_config_flow.py
@@ -6,6 +6,7 @@ import pytest
from homeassistant import data_entry_flow
from homeassistant.components.notion import DOMAIN, config_flow
+from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from tests.common import MockConfigEntry, mock_coro
@@ -29,12 +30,16 @@ async def test_duplicate_error(hass):
"""Test that errors are shown when duplicates are added."""
conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}
- MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
- flow = config_flow.NotionFlowHandler()
- flow.hass = hass
+ MockConfigEntry(domain=DOMAIN, unique_id="user@host.com", data=conf).add_to_hass(
+ hass
+ )
- result = await flow.async_step_user(user_input=conf)
- assert result["errors"] == {CONF_USERNAME: "identifier_exists"}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=conf
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
@@ -46,6 +51,7 @@ async def test_invalid_credentials(hass, mock_aionotion):
flow = config_flow.NotionFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=conf)
assert result["errors"] == {"base": "invalid_credentials"}
@@ -55,6 +61,7 @@ async def test_show_form(hass):
"""Test that the form is served with no input."""
flow = config_flow.NotionFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=None)
@@ -68,6 +75,7 @@ async def test_step_import(hass, mock_aionotion):
flow = config_flow.NotionFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_import(import_config=conf)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -84,6 +92,7 @@ async def test_step_user(hass, mock_aionotion):
flow = config_flow.NotionFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=conf)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py
index 730da4bc7b2..ae9fb65c615 100644
--- a/tests/components/owntracks/test_device_tracker.py
+++ b/tests/components/owntracks/test_device_tracker.py
@@ -1565,3 +1565,72 @@ async def test_restore_state(hass, hass_client):
assert state_1.attributes["longitude"] == state_2.attributes["longitude"]
assert state_1.attributes["battery_level"] == state_2.attributes["battery_level"]
assert state_1.attributes["source_type"] == state_2.attributes["source_type"]
+
+
+async def test_returns_empty_friends(hass, hass_client):
+ """Test that an empty list of persons' locations is returned."""
+ entry = MockConfigEntry(
+ domain="owntracks", data={"webhook_id": "owntracks_test", "secret": "abcd"}
+ )
+ entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ client = await hass_client()
+ resp = await client.post(
+ "/api/webhook/owntracks_test",
+ json=LOCATION_MESSAGE,
+ headers={"X-Limit-u": "Paulus", "X-Limit-d": "Pixel"},
+ )
+
+ assert resp.status == 200
+ assert await resp.text() == "[]"
+
+
+async def test_returns_array_friends(hass, hass_client):
+ """Test that a list of persons' current locations is returned."""
+ otracks = MockConfigEntry(
+ domain="owntracks", data={"webhook_id": "owntracks_test", "secret": "abcd"}
+ )
+ otracks.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(otracks.entry_id)
+ await hass.async_block_till_done()
+
+ # Setup device_trackers
+ assert await async_setup_component(
+ hass,
+ "person",
+ {
+ "person": [
+ {
+ "name": "person 1",
+ "id": "person1",
+ "device_trackers": ["device_tracker.person_1_tracker_1"],
+ },
+ {
+ "name": "person2",
+ "id": "person2",
+ "device_trackers": ["device_tracker.person_2_tracker_1"],
+ },
+ ]
+ },
+ )
+ hass.states.async_set(
+ "device_tracker.person_1_tracker_1", "home", {"latitude": 10, "longitude": 20}
+ )
+
+ client = await hass_client()
+ resp = await client.post(
+ "/api/webhook/owntracks_test",
+ json=LOCATION_MESSAGE,
+ headers={"X-Limit-u": "Paulus", "X-Limit-d": "Pixel"},
+ )
+
+ assert resp.status == 200
+ response_json = json.loads(await resp.text())
+
+ assert response_json[0]["lat"] == 10
+ assert response_json[0]["lon"] == 20
+ assert response_json[0]["tid"] == "p1"
diff --git a/tests/components/panel_custom/test_init.py b/tests/components/panel_custom/test_init.py
index e6bc56d080e..5f7161089f6 100644
--- a/tests/components/panel_custom/test_init.py
+++ b/tests/components/panel_custom/test_init.py
@@ -181,3 +181,17 @@ async def test_url_option_conflict(hass):
for config in to_try:
result = await setup.async_setup_component(hass, "panel_custom", config)
assert not result
+
+
+async def test_url_path_conflict(hass):
+ """Test config with overlapping url path."""
+ assert await setup.async_setup_component(
+ hass,
+ "panel_custom",
+ {
+ "panel_custom": [
+ {"name": "todo-mvc", "js_url": "/local/bla.js"},
+ {"name": "todo-mvc", "js_url": "/local/bla.js"},
+ ]
+ },
+ )
diff --git a/tests/components/plex/const.py b/tests/components/plex/const.py
new file mode 100644
index 00000000000..0f91a9da23f
--- /dev/null
+++ b/tests/components/plex/const.py
@@ -0,0 +1,52 @@
+"""Constants used by Plex tests."""
+from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
+from homeassistant.components.plex import const
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_PORT,
+ CONF_TOKEN,
+ CONF_URL,
+ CONF_VERIFY_SSL,
+)
+
+MOCK_SERVERS = [
+ {
+ CONF_HOST: "1.2.3.4",
+ CONF_PORT: 32400,
+ const.CONF_SERVER: "Plex Server 1",
+ const.CONF_SERVER_IDENTIFIER: "unique_id_123",
+ },
+ {
+ CONF_HOST: "4.3.2.1",
+ CONF_PORT: 32400,
+ const.CONF_SERVER: "Plex Server 2",
+ const.CONF_SERVER_IDENTIFIER: "unique_id_456",
+ },
+]
+
+MOCK_USERS = {
+ "Owner": {"enabled": True},
+ "b": {"enabled": True},
+ "c": {"enabled": True},
+}
+
+MOCK_TOKEN = "secret_token"
+
+DEFAULT_DATA = {
+ const.CONF_SERVER: MOCK_SERVERS[0][const.CONF_SERVER],
+ const.PLEX_SERVER_CONFIG: {
+ const.CONF_CLIENT_IDENTIFIER: "00000000-0000-0000-0000-000000000000",
+ CONF_TOKEN: MOCK_TOKEN,
+ CONF_URL: f"https://{MOCK_SERVERS[0][CONF_HOST]}:{MOCK_SERVERS[0][CONF_PORT]}",
+ CONF_VERIFY_SSL: True,
+ },
+ const.CONF_SERVER_IDENTIFIER: MOCK_SERVERS[0][const.CONF_SERVER_IDENTIFIER],
+}
+
+DEFAULT_OPTIONS = {
+ MP_DOMAIN: {
+ const.CONF_IGNORE_NEW_SHARED_USERS: False,
+ const.CONF_MONITORED_USERS: MOCK_USERS,
+ const.CONF_USE_EPISODE_ART: False,
+ }
+}
diff --git a/tests/components/plex/mock_classes.py b/tests/components/plex/mock_classes.py
index 6e61dfac3ab..9b59190173f 100644
--- a/tests/components/plex/mock_classes.py
+++ b/tests/components/plex/mock_classes.py
@@ -1,29 +1,12 @@
"""Mock classes used in tests."""
-import itertools
+from homeassistant.components.plex.const import (
+ CONF_SERVER,
+ CONF_SERVER_IDENTIFIER,
+ PLEX_SERVER_CONFIG,
+)
+from homeassistant.const import CONF_URL
-from homeassistant.components.plex.const import CONF_SERVER, CONF_SERVER_IDENTIFIER
-from homeassistant.const import CONF_HOST, CONF_PORT
-
-MOCK_SERVERS = [
- {
- CONF_HOST: "1.2.3.4",
- CONF_PORT: 32400,
- CONF_SERVER: "Plex Server 1",
- CONF_SERVER_IDENTIFIER: "unique_id_123",
- },
- {
- CONF_HOST: "4.3.2.1",
- CONF_PORT: 32400,
- CONF_SERVER: "Plex Server 2",
- CONF_SERVER_IDENTIFIER: "unique_id_456",
- },
-]
-
-MOCK_MONITORED_USERS = {
- "a": {"enabled": True},
- "b": {"enabled": False},
- "c": {"enabled": True},
-}
+from .const import DEFAULT_DATA, MOCK_SERVERS, MOCK_USERS
class MockResource:
@@ -64,10 +47,11 @@ class MockPlexAccount:
class MockPlexSystemAccount:
"""Mock a PlexSystemAccount instance."""
- def __init__(self):
+ def __init__(self, index):
"""Initialize the object."""
- self.name = "Dummy"
- self.accountID = 1
+ # Start accountIDs at 1 to set proper owner.
+ self.name = list(MOCK_USERS)[index]
+ self.accountID = index + 1
class MockPlexServer:
@@ -76,68 +60,179 @@ class MockPlexServer:
def __init__(
self,
index=0,
- ssl=True,
- load_users=True,
- num_users=len(MOCK_MONITORED_USERS),
- ignore_new_users=False,
+ config_entry=None,
+ num_users=len(MOCK_USERS),
+ session_type="video",
):
"""Initialize the object."""
- host = MOCK_SERVERS[index][CONF_HOST]
- port = MOCK_SERVERS[index][CONF_PORT]
- self.friendlyName = MOCK_SERVERS[index][ # pylint: disable=invalid-name
- CONF_SERVER
+ if config_entry:
+ self._data = config_entry.data
+ else:
+ self._data = DEFAULT_DATA
+
+ self._baseurl = self._data[PLEX_SERVER_CONFIG][CONF_URL]
+ self.friendlyName = self._data[CONF_SERVER]
+ self.machineIdentifier = self._data[CONF_SERVER_IDENTIFIER]
+
+ self._systemAccounts = list(map(MockPlexSystemAccount, range(num_users)))
+
+ self._clients = []
+ self._sessions = []
+ self.set_clients(num_users)
+ self.set_sessions(num_users, session_type)
+
+ def set_clients(self, num_clients):
+ """Set up mock PlexClients for this PlexServer."""
+ self._clients = [MockPlexClient(self._baseurl, x) for x in range(num_clients)]
+
+ def set_sessions(self, num_sessions, session_type):
+ """Set up mock PlexSessions for this PlexServer."""
+ self._sessions = [
+ MockPlexSession(self._clients[x], mediatype=session_type, index=x)
+ for x in range(num_sessions)
]
- self.machineIdentifier = MOCK_SERVERS[index][ # pylint: disable=invalid-name
- CONF_SERVER_IDENTIFIER
- ]
- prefix = "https" if ssl else "http"
- self._baseurl = f"{prefix}://{host}:{port}"
- self._systemAccount = MockPlexSystemAccount()
- self._ignore_new_users = ignore_new_users
- self._load_users = load_users
- self._num_users = num_users
+
+ def clear_clients(self):
+ """Clear all active PlexClients."""
+ self._clients = []
+
+ def clear_sessions(self):
+ """Clear all active PlexSessions."""
+ self._sessions = []
+
+ def clients(self):
+ """Mock the clients method."""
+ return self._clients
+
+ def sessions(self):
+ """Mock the sessions method."""
+ return self._sessions
def systemAccounts(self):
"""Mock the systemAccounts lookup method."""
- return [self._systemAccount]
+ return self._systemAccounts
+
+ def url(self, path, includeToken=False):
+ """Mock method to generate a server URL."""
+ return f"{self._baseurl}{path}"
@property
def accounts(self):
"""Mock the accounts property."""
- return set(["a", "b", "c"])
-
- @property
- def owner(self):
- """Mock the owner property."""
- return "a"
-
- @property
- def url_in_use(self):
- """Return URL used by PlexServer."""
- return self._baseurl
+ return set(MOCK_USERS)
@property
def version(self):
"""Mock version of PlexServer."""
return "1.0"
- @property
- def option_monitored_users(self):
- """Mock loaded config option for monitored users."""
- userdict = dict(itertools.islice(MOCK_MONITORED_USERS.items(), self._num_users))
- return userdict if self._load_users else {}
+
+class MockPlexClient:
+ """Mock a PlexClient instance."""
+
+ def __init__(self, url, index=0):
+ """Initialize the object."""
+ self.machineIdentifier = f"client-{index+1}"
+ self._baseurl = url
+
+ def url(self, key):
+ """Mock the url method."""
+ return f"{self._baseurl}{key}"
@property
- def option_ignore_new_shared_users(self):
- """Mock loaded config option for ignoring new users."""
- return self._ignore_new_users
+ def device(self):
+ """Mock the device attribute."""
+ return "DEVICE"
@property
- def option_show_all_controls(self):
- """Mock loaded config option for showing all controls."""
- return False
+ def platform(self):
+ """Mock the platform attribute."""
+ return "PLATFORM"
@property
- def option_use_episode_art(self):
- """Mock loaded config option for using episode art."""
- return False
+ def product(self):
+ """Mock the product attribute."""
+ return "PRODUCT"
+
+ @property
+ def protocolCapabilities(self):
+ """Mock the protocolCapabilities attribute."""
+ return ["player"]
+
+ @property
+ def state(self):
+ """Mock the state attribute."""
+ return "playing"
+
+ @property
+ def title(self):
+ """Mock the title attribute."""
+ return "TITLE"
+
+ @property
+ def version(self):
+ """Mock the version attribute."""
+ return "1.0"
+
+
+class MockPlexSession:
+ """Mock a PlexServer.sessions() instance."""
+
+ def __init__(self, player, mediatype, index=0):
+ """Initialize the object."""
+ self.TYPE = mediatype
+ self.usernames = [list(MOCK_USERS)[index]]
+ self.players = [player]
+ self._section = MockPlexLibrarySection()
+
+ @property
+ def duration(self):
+ """Mock the duration attribute."""
+ return 10000000
+
+ @property
+ def ratingKey(self):
+ """Mock the ratingKey attribute."""
+ return 123
+
+ def section(self):
+ """Mock the section method."""
+ return self._section
+
+ @property
+ def summary(self):
+ """Mock the summary attribute."""
+ return "SUMMARY"
+
+ @property
+ def thumbUrl(self):
+ """Mock the thumbUrl attribute."""
+ return "http://1.2.3.4/thumb"
+
+ @property
+ def title(self):
+ """Mock the title attribute."""
+ return "TITLE"
+
+ @property
+ def type(self):
+ """Mock the type attribute."""
+ return "movie"
+
+ @property
+ def viewOffset(self):
+ """Mock the viewOffset attribute."""
+ return 0
+
+ @property
+ def year(self):
+ """Mock the year attribute."""
+ return 2020
+
+
+class MockPlexLibrarySection:
+ """Mock a Plex LibrarySection instance."""
+
+ def __init__(self, library="Movies"):
+ """Initialize the object."""
+ self.title = library
diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py
index b331444123a..d839ccc674b 100644
--- a/tests/components/plex/test_config_flow.py
+++ b/tests/components/plex/test_config_flow.py
@@ -1,56 +1,46 @@
"""Tests for Plex config flow."""
import copy
-from unittest.mock import patch
-import asynctest
+from asynctest import patch
import plexapi.exceptions
import requests.exceptions
+from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.components.plex import config_flow
-from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_TOKEN, CONF_URL
+from homeassistant.components.plex.const import (
+ CONF_IGNORE_NEW_SHARED_USERS,
+ CONF_MONITORED_USERS,
+ CONF_SERVER,
+ CONF_SERVER_IDENTIFIER,
+ 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 .mock_classes import MOCK_SERVERS, MockPlexAccount, MockPlexServer
+from .const import DEFAULT_DATA, DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN
+from .mock_classes import MockPlexAccount, MockPlexServer
from tests.common import MockConfigEntry
-MOCK_TOKEN = "secret_token"
-MOCK_FILE_CONTENTS = {
- f"{MOCK_SERVERS[0][CONF_HOST]}:{MOCK_SERVERS[0][CONF_PORT]}": {
- "ssl": False,
- "token": MOCK_TOKEN,
- "verify": True,
- }
-}
-
-DEFAULT_OPTIONS = {
- config_flow.MP_DOMAIN: {
- config_flow.CONF_USE_EPISODE_ART: False,
- config_flow.CONF_SHOW_ALL_CONTROLS: False,
- config_flow.CONF_IGNORE_NEW_SHARED_USERS: False,
- }
-}
-
-
-def init_config_flow(hass):
- """Init a configuration flow."""
- flow = config_flow.PlexFlowHandler()
- flow.hass = hass
- return flow
-
async def test_bad_credentials(hass):
"""Test when provided credentials are rejected."""
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
assert result["type"] == "form"
assert result["step_id"] == "start_website_auth"
with patch(
"plexapi.myplex.MyPlexAccount", side_effect=plexapi.exceptions.Unauthorized
- ), asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch(
+ ), patch("plexauth.PlexAuth.initiate_auth"), patch(
"plexauth.PlexAuth.token", return_value="BAD TOKEN"
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
@@ -66,77 +56,6 @@ async def test_bad_credentials(hass):
assert result["errors"]["base"] == "faulty_credentials"
-async def test_import_file_from_discovery(hass):
- """Test importing a legacy file during discovery."""
-
- file_host_and_port, file_config = list(MOCK_FILE_CONTENTS.items())[0]
- file_use_ssl = file_config[CONF_SSL]
- file_prefix = "https" if file_use_ssl else "http"
- used_url = f"{file_prefix}://{file_host_and_port}"
-
- mock_plex_server = MockPlexServer(ssl=file_use_ssl)
-
- with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
- "homeassistant.components.plex.config_flow.load_json",
- return_value=MOCK_FILE_CONTENTS,
- ):
-
- result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN,
- context={"source": "discovery"},
- data={
- CONF_HOST: MOCK_SERVERS[0][CONF_HOST],
- CONF_PORT: MOCK_SERVERS[0][CONF_PORT],
- },
- )
- assert result["type"] == "create_entry"
- assert result["title"] == mock_plex_server.friendlyName
- assert result["data"][config_flow.CONF_SERVER] == mock_plex_server.friendlyName
- assert (
- result["data"][config_flow.CONF_SERVER_IDENTIFIER]
- == mock_plex_server.machineIdentifier
- )
- assert result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_URL] == used_url
- assert (
- result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_TOKEN]
- == file_config[CONF_TOKEN]
- )
-
-
-async def test_discovery(hass):
- """Test starting a flow from discovery."""
- with patch("homeassistant.components.plex.config_flow.load_json", return_value={}):
- result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN,
- context={"source": "discovery"},
- data={
- CONF_HOST: MOCK_SERVERS[0][CONF_HOST],
- CONF_PORT: MOCK_SERVERS[0][CONF_PORT],
- },
- )
- assert result["type"] == "abort"
- assert result["reason"] == "discovery_no_file"
-
-
-async def test_discovery_while_in_progress(hass):
- """Test starting a flow from discovery."""
-
- await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
- )
-
- result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN,
- context={"source": "discovery"},
- data={
- CONF_HOST: MOCK_SERVERS[0][CONF_HOST],
- CONF_PORT: MOCK_SERVERS[0][CONF_PORT],
- },
- )
- assert result["type"] == "abort"
- assert result["reason"] == "already_configured"
-
-
async def test_import_success(hass):
"""Test a successful configuration import."""
@@ -144,7 +63,7 @@ async def test_import_success(hass):
with patch("plexapi.server.PlexServer", return_value=mock_plex_server):
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN,
+ DOMAIN,
context={"source": "import"},
data={
CONF_TOKEN: MOCK_TOKEN,
@@ -154,16 +73,10 @@ async def test_import_success(hass):
assert result["type"] == "create_entry"
assert result["title"] == mock_plex_server.friendlyName
- assert result["data"][config_flow.CONF_SERVER] == mock_plex_server.friendlyName
- assert (
- result["data"][config_flow.CONF_SERVER_IDENTIFIER]
- == mock_plex_server.machineIdentifier
- )
- assert (
- result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_URL]
- == mock_plex_server.url_in_use
- )
- assert result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN
+ assert result["data"][CONF_SERVER] == mock_plex_server.friendlyName
+ assert result["data"][CONF_SERVER_IDENTIFIER] == mock_plex_server.machineIdentifier
+ assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server._baseurl
+ assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN
async def test_import_bad_hostname(hass):
@@ -173,7 +86,7 @@ async def test_import_bad_hostname(hass):
"plexapi.server.PlexServer", side_effect=requests.exceptions.ConnectionError
):
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN,
+ DOMAIN,
context={"source": "import"},
data={
CONF_TOKEN: MOCK_TOKEN,
@@ -188,14 +101,14 @@ async def test_unknown_exception(hass):
"""Test when an unknown exception is encountered."""
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
assert result["type"] == "form"
assert result["step_id"] == "start_website_auth"
- with patch("plexapi.myplex.MyPlexAccount", side_effect=Exception), asynctest.patch(
+ with patch("plexapi.myplex.MyPlexAccount", side_effect=Exception), patch(
"plexauth.PlexAuth.initiate_auth"
- ), asynctest.patch("plexauth.PlexAuth.token", return_value="MOCK_TOKEN"):
+ ), patch("plexauth.PlexAuth.token", return_value="MOCK_TOKEN"):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == "external"
@@ -213,14 +126,14 @@ async def test_no_servers_found(hass):
await async_setup_component(hass, "http", {"http": {}})
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
assert result["type"] == "form"
assert result["step_id"] == "start_website_auth"
with patch(
"plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount(servers=0)
- ), asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch(
+ ), patch("plexauth.PlexAuth.initiate_auth"), patch(
"plexauth.PlexAuth.token", return_value=MOCK_TOKEN
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
@@ -243,14 +156,14 @@ async def test_single_available_server(hass):
await async_setup_component(hass, "http", {"http": {}})
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
assert result["type"] == "form"
assert result["step_id"] == "start_website_auth"
with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()), patch(
"plexapi.server.PlexServer", return_value=mock_plex_server
- ), asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch(
+ ), patch("plexauth.PlexAuth.initiate_auth"), patch(
"plexauth.PlexAuth.token", return_value=MOCK_TOKEN
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
@@ -262,16 +175,12 @@ async def test_single_available_server(hass):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == "create_entry"
assert result["title"] == mock_plex_server.friendlyName
- assert result["data"][config_flow.CONF_SERVER] == mock_plex_server.friendlyName
+ assert result["data"][CONF_SERVER] == mock_plex_server.friendlyName
assert (
- result["data"][config_flow.CONF_SERVER_IDENTIFIER]
- == mock_plex_server.machineIdentifier
+ result["data"][CONF_SERVER_IDENTIFIER] == mock_plex_server.machineIdentifier
)
- assert (
- result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_URL]
- == mock_plex_server.url_in_use
- )
- assert result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN
+ assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server._baseurl
+ assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN
async def test_multiple_servers_with_selection(hass):
@@ -282,18 +191,16 @@ async def test_multiple_servers_with_selection(hass):
await async_setup_component(hass, "http", {"http": {}})
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
assert result["type"] == "form"
assert result["step_id"] == "start_website_auth"
with patch(
"plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount(servers=2)
- ), patch(
- "plexapi.server.PlexServer", return_value=mock_plex_server
- ), asynctest.patch(
+ ), patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
"plexauth.PlexAuth.initiate_auth"
- ), asynctest.patch(
+ ), patch(
"plexauth.PlexAuth.token", return_value=MOCK_TOKEN
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
@@ -307,23 +214,16 @@ async def test_multiple_servers_with_selection(hass):
assert result["step_id"] == "select_server"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"],
- user_input={
- config_flow.CONF_SERVER: MOCK_SERVERS[0][config_flow.CONF_SERVER]
- },
+ result["flow_id"], user_input={CONF_SERVER: MOCK_SERVERS[0][CONF_SERVER]},
)
assert result["type"] == "create_entry"
assert result["title"] == mock_plex_server.friendlyName
- assert result["data"][config_flow.CONF_SERVER] == mock_plex_server.friendlyName
+ assert result["data"][CONF_SERVER] == mock_plex_server.friendlyName
assert (
- result["data"][config_flow.CONF_SERVER_IDENTIFIER]
- == mock_plex_server.machineIdentifier
+ result["data"][CONF_SERVER_IDENTIFIER] == mock_plex_server.machineIdentifier
)
- assert (
- result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_URL]
- == mock_plex_server.url_in_use
- )
- assert result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN
+ assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server._baseurl
+ assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN
async def test_adding_last_unconfigured_server(hass):
@@ -334,28 +234,24 @@ async def test_adding_last_unconfigured_server(hass):
await async_setup_component(hass, "http", {"http": {}})
MockConfigEntry(
- domain=config_flow.DOMAIN,
+ domain=DOMAIN,
data={
- config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVERS[1][
- config_flow.CONF_SERVER_IDENTIFIER
- ],
- config_flow.CONF_SERVER: MOCK_SERVERS[1][config_flow.CONF_SERVER],
+ CONF_SERVER_IDENTIFIER: MOCK_SERVERS[1][CONF_SERVER_IDENTIFIER],
+ CONF_SERVER: MOCK_SERVERS[1][CONF_SERVER],
},
).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
assert result["type"] == "form"
assert result["step_id"] == "start_website_auth"
with patch(
"plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount(servers=2)
- ), patch(
- "plexapi.server.PlexServer", return_value=mock_plex_server
- ), asynctest.patch(
+ ), patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
"plexauth.PlexAuth.initiate_auth"
- ), asynctest.patch(
+ ), patch(
"plexauth.PlexAuth.token", return_value=MOCK_TOKEN
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
@@ -367,16 +263,12 @@ async def test_adding_last_unconfigured_server(hass):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == "create_entry"
assert result["title"] == mock_plex_server.friendlyName
- assert result["data"][config_flow.CONF_SERVER] == mock_plex_server.friendlyName
+ assert result["data"][CONF_SERVER] == mock_plex_server.friendlyName
assert (
- result["data"][config_flow.CONF_SERVER_IDENTIFIER]
- == mock_plex_server.machineIdentifier
+ result["data"][CONF_SERVER_IDENTIFIER] == mock_plex_server.machineIdentifier
)
- assert (
- result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_URL]
- == mock_plex_server.url_in_use
- )
- assert result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN
+ assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server._baseurl
+ assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN
async def test_already_configured(hass):
@@ -384,28 +276,25 @@ async def test_already_configured(hass):
mock_plex_server = MockPlexServer()
- flow = init_config_flow(hass)
- flow.context = {"source": "import"}
MockConfigEntry(
- domain=config_flow.DOMAIN,
+ domain=DOMAIN,
data={
- config_flow.CONF_SERVER: MOCK_SERVERS[0][config_flow.CONF_SERVER],
- config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVERS[0][
- config_flow.CONF_SERVER_IDENTIFIER
- ],
+ CONF_SERVER: MOCK_SERVERS[0][CONF_SERVER],
+ CONF_SERVER_IDENTIFIER: MOCK_SERVERS[0][CONF_SERVER_IDENTIFIER],
},
+ unique_id=MOCK_SERVERS[0][CONF_SERVER_IDENTIFIER],
).add_to_hass(hass)
- with patch(
- "plexapi.server.PlexServer", return_value=mock_plex_server
- ), asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch(
- "plexauth.PlexAuth.token", return_value=MOCK_TOKEN
- ):
- result = await flow.async_step_import(
- {
+ with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
+ "plexauth.PlexAuth.initiate_auth"
+ ), patch("plexauth.PlexAuth.token", return_value=MOCK_TOKEN):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": "import"},
+ data={
CONF_TOKEN: MOCK_TOKEN,
CONF_URL: f"http://{MOCK_SERVERS[0][CONF_HOST]}:{MOCK_SERVERS[0][CONF_PORT]}",
- }
+ },
)
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
@@ -417,34 +306,30 @@ async def test_all_available_servers_configured(hass):
await async_setup_component(hass, "http", {"http": {}})
MockConfigEntry(
- domain=config_flow.DOMAIN,
+ domain=DOMAIN,
data={
- config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVERS[0][
- config_flow.CONF_SERVER_IDENTIFIER
- ],
- config_flow.CONF_SERVER: MOCK_SERVERS[0][config_flow.CONF_SERVER],
+ CONF_SERVER_IDENTIFIER: MOCK_SERVERS[0][CONF_SERVER_IDENTIFIER],
+ CONF_SERVER: MOCK_SERVERS[0][CONF_SERVER],
},
).add_to_hass(hass)
MockConfigEntry(
- domain=config_flow.DOMAIN,
+ domain=DOMAIN,
data={
- config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVERS[1][
- config_flow.CONF_SERVER_IDENTIFIER
- ],
- config_flow.CONF_SERVER: MOCK_SERVERS[1][config_flow.CONF_SERVER],
+ CONF_SERVER_IDENTIFIER: MOCK_SERVERS[1][CONF_SERVER_IDENTIFIER],
+ CONF_SERVER: MOCK_SERVERS[1][CONF_SERVER],
},
).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
assert result["type"] == "form"
assert result["step_id"] == "start_website_auth"
with patch(
"plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount(servers=2)
- ), asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch(
+ ), patch("plexauth.PlexAuth.initiate_auth"), patch(
"plexauth.PlexAuth.token", return_value=MOCK_TOKEN
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
@@ -460,20 +345,26 @@ async def test_all_available_servers_configured(hass):
async def test_option_flow(hass):
"""Test config options flow selection."""
-
- mock_plex_server = MockPlexServer(load_users=False)
-
- MOCK_SERVER_ID = MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER]
- hass.data[config_flow.DOMAIN] = {
- config_flow.SERVERS: {MOCK_SERVER_ID: mock_plex_server}
- }
+ mock_plex_server = MockPlexServer()
entry = MockConfigEntry(
- domain=config_flow.DOMAIN,
- data={config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVER_ID},
+ domain=DOMAIN,
+ data=DEFAULT_DATA,
options=DEFAULT_OPTIONS,
+ unique_id=DEFAULT_DATA["server_id"],
)
- entry.add_to_hass(hass)
+
+ with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
+ "homeassistant.components.plex.PlexWebsocket.listen"
+ ) as mock_listen:
+ entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert mock_listen.called
+
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+ assert entry.state == ENTRY_STATE_LOADED
result = await hass.config_entries.options.async_init(
entry.entry_id, context={"source": "test"}, data=None
@@ -484,118 +375,69 @@ async def test_option_flow(hass):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
- config_flow.CONF_USE_EPISODE_ART: True,
- config_flow.CONF_SHOW_ALL_CONTROLS: True,
- config_flow.CONF_IGNORE_NEW_SHARED_USERS: True,
- config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts),
+ CONF_USE_EPISODE_ART: True,
+ CONF_IGNORE_NEW_SHARED_USERS: True,
+ CONF_MONITORED_USERS: list(mock_plex_server.accounts),
},
)
assert result["type"] == "create_entry"
assert result["data"] == {
- config_flow.MP_DOMAIN: {
- config_flow.CONF_USE_EPISODE_ART: True,
- config_flow.CONF_SHOW_ALL_CONTROLS: True,
- config_flow.CONF_IGNORE_NEW_SHARED_USERS: True,
- config_flow.CONF_MONITORED_USERS: {
+ MP_DOMAIN: {
+ CONF_USE_EPISODE_ART: True,
+ CONF_IGNORE_NEW_SHARED_USERS: True,
+ CONF_MONITORED_USERS: {
user: {"enabled": True} for user in mock_plex_server.accounts
},
}
}
-async def test_option_flow_loading_saved_users(hass):
- """Test config options flow selection when loading existing user config."""
+async def test_option_flow_new_users_available(hass, caplog):
+ """Test config options multiselect defaults when new Plex users are seen."""
- mock_plex_server = MockPlexServer(load_users=True)
-
- MOCK_SERVER_ID = MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER]
- hass.data[config_flow.DOMAIN] = {
- config_flow.SERVERS: {MOCK_SERVER_ID: mock_plex_server}
- }
+ OPTIONS_OWNER_ONLY = copy.deepcopy(DEFAULT_OPTIONS)
+ OPTIONS_OWNER_ONLY[MP_DOMAIN][CONF_MONITORED_USERS] = {"Owner": {"enabled": True}}
entry = MockConfigEntry(
- domain=config_flow.DOMAIN,
- data={config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVER_ID},
- options=DEFAULT_OPTIONS,
+ domain=DOMAIN,
+ data=DEFAULT_DATA,
+ options=OPTIONS_OWNER_ONLY,
+ unique_id=DEFAULT_DATA["server_id"],
)
- entry.add_to_hass(hass)
+
+ 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
+
+ async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
+ await hass.async_block_till_done()
+
+ monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users
+
+ new_users = [x for x in mock_plex_server.accounts if x not in monitored_users]
+ assert len(monitored_users) == 1
+ assert len(new_users) == 2
+
+ sensor = hass.states.get("sensor.plex_plex_server_1")
+ assert sensor.state == str(len(mock_plex_server.accounts))
result = await hass.config_entries.options.async_init(
entry.entry_id, context={"source": "test"}, data=None
)
assert result["type"] == "form"
assert result["step_id"] == "plex_mp_settings"
+ multiselect_defaults = result["data_schema"].schema["monitored_users"].options
- result = await hass.config_entries.options.async_configure(
- result["flow_id"],
- user_input={
- config_flow.CONF_USE_EPISODE_ART: True,
- config_flow.CONF_SHOW_ALL_CONTROLS: True,
- config_flow.CONF_IGNORE_NEW_SHARED_USERS: True,
- config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts),
- },
- )
- assert result["type"] == "create_entry"
- assert result["data"] == {
- config_flow.MP_DOMAIN: {
- config_flow.CONF_USE_EPISODE_ART: True,
- config_flow.CONF_SHOW_ALL_CONTROLS: True,
- config_flow.CONF_IGNORE_NEW_SHARED_USERS: True,
- config_flow.CONF_MONITORED_USERS: {
- user: {"enabled": True} for user in mock_plex_server.accounts
- },
- }
- }
-
-
-async def test_option_flow_new_users_available(hass):
- """Test config options flow selection when new Plex accounts available."""
-
- mock_plex_server = MockPlexServer(load_users=True, num_users=2)
-
- MOCK_SERVER_ID = MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER]
- hass.data[config_flow.DOMAIN] = {
- config_flow.SERVERS: {MOCK_SERVER_ID: mock_plex_server}
- }
-
- OPTIONS_WITH_USERS = copy.deepcopy(DEFAULT_OPTIONS)
- OPTIONS_WITH_USERS[config_flow.MP_DOMAIN][config_flow.CONF_MONITORED_USERS] = {
- "a": {"enabled": True}
- }
-
- entry = MockConfigEntry(
- domain=config_flow.DOMAIN,
- data={config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVER_ID},
- options=OPTIONS_WITH_USERS,
- )
- entry.add_to_hass(hass)
-
- result = await hass.config_entries.options.async_init(
- entry.entry_id, context={"source": "test"}, data=None
- )
- assert result["type"] == "form"
- assert result["step_id"] == "plex_mp_settings"
-
- result = await hass.config_entries.options.async_configure(
- result["flow_id"],
- user_input={
- config_flow.CONF_USE_EPISODE_ART: True,
- config_flow.CONF_SHOW_ALL_CONTROLS: True,
- config_flow.CONF_IGNORE_NEW_SHARED_USERS: True,
- config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts),
- },
- )
- assert result["type"] == "create_entry"
- assert result["data"] == {
- config_flow.MP_DOMAIN: {
- config_flow.CONF_USE_EPISODE_ART: True,
- config_flow.CONF_SHOW_ALL_CONTROLS: True,
- config_flow.CONF_IGNORE_NEW_SHARED_USERS: True,
- config_flow.CONF_MONITORED_USERS: {
- user: {"enabled": True} for user in mock_plex_server.accounts
- },
- }
- }
+ assert "[Owner]" in multiselect_defaults["Owner"]
+ for user in new_users:
+ assert "[New]" in multiselect_defaults[user]
async def test_external_timed_out(hass):
@@ -604,12 +446,12 @@ async def test_external_timed_out(hass):
await async_setup_component(hass, "http", {"http": {}})
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
assert result["type"] == "form"
assert result["step_id"] == "start_website_auth"
- with asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch(
+ with patch("plexauth.PlexAuth.initiate_auth"), patch(
"plexauth.PlexAuth.token", return_value=None
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
@@ -629,12 +471,12 @@ async def test_callback_view(hass, aiohttp_client):
await async_setup_component(hass, "http", {"http": {}})
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": "user"}
+ DOMAIN, context={"source": "user"}
)
assert result["type"] == "form"
assert result["step_id"] == "start_website_auth"
- with asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch(
+ with patch("plexauth.PlexAuth.initiate_auth"), patch(
"plexauth.PlexAuth.token", return_value=MOCK_TOKEN
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
@@ -652,13 +494,11 @@ async def test_multiple_servers_with_import(hass):
with patch(
"plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount(servers=2)
- ), asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch(
+ ), patch("plexauth.PlexAuth.initiate_auth"), patch(
"plexauth.PlexAuth.token", return_value=MOCK_TOKEN
):
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN,
- context={"source": "import"},
- data={CONF_TOKEN: MOCK_TOKEN},
+ DOMAIN, context={"source": "import"}, data={CONF_TOKEN: MOCK_TOKEN},
)
assert result["type"] == "abort"
assert result["reason"] == "non-interactive"
diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py
new file mode 100644
index 00000000000..3358ac1c2cb
--- /dev/null
+++ b/tests/components/plex/test_init.py
@@ -0,0 +1,302 @@
+"""Tests for Plex setup."""
+import copy
+from datetime import timedelta
+
+from asynctest import patch
+import plexapi
+import requests
+
+from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
+import homeassistant.components.plex.const as const
+from homeassistant.config_entries import (
+ ENTRY_STATE_LOADED,
+ ENTRY_STATE_NOT_LOADED,
+ ENTRY_STATE_SETUP_ERROR,
+ ENTRY_STATE_SETUP_RETRY,
+)
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_PORT,
+ CONF_SSL,
+ CONF_TOKEN,
+ 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 .const import DEFAULT_DATA, DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN
+from .mock_classes import MockPlexServer
+
+from tests.common import MockConfigEntry, async_fire_time_changed
+
+
+async def test_setup_with_config(hass):
+ """Test setup component with config."""
+ config = {
+ const.DOMAIN: {
+ CONF_HOST: MOCK_SERVERS[0][CONF_HOST],
+ CONF_PORT: MOCK_SERVERS[0][CONF_PORT],
+ CONF_TOKEN: MOCK_TOKEN,
+ CONF_SSL: True,
+ CONF_VERIFY_SSL: True,
+ MP_DOMAIN: {
+ const.CONF_IGNORE_NEW_SHARED_USERS: False,
+ const.CONF_USE_EPISODE_ART: False,
+ },
+ },
+ }
+
+ mock_plex_server = MockPlexServer()
+
+ with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
+ "homeassistant.components.plex.PlexWebsocket.listen"
+ ) as mock_listen:
+ assert await async_setup_component(hass, const.DOMAIN, config) is True
+ await hass.async_block_till_done()
+
+ assert mock_listen.called
+ assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1
+ entry = hass.config_entries.async_entries(const.DOMAIN)[0]
+ assert entry.state == ENTRY_STATE_LOADED
+
+ server_id = mock_plex_server.machineIdentifier
+ loaded_server = hass.data[const.DOMAIN][const.SERVERS][server_id]
+
+ assert loaded_server.plex_server == mock_plex_server
+
+ assert server_id in hass.data[const.DOMAIN][const.DISPATCHERS]
+ assert server_id in hass.data[const.DOMAIN][const.WEBSOCKETS]
+ assert (
+ hass.data[const.DOMAIN][const.PLATFORMS_COMPLETED][server_id] == const.PLATFORMS
+ )
+
+
+async def test_setup_with_config_entry(hass):
+ """Test setup component with config."""
+
+ mock_plex_server = MockPlexServer()
+
+ entry = MockConfigEntry(
+ domain=const.DOMAIN,
+ data=DEFAULT_DATA,
+ options=DEFAULT_OPTIONS,
+ unique_id=DEFAULT_DATA["server_id"],
+ )
+
+ with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
+ "homeassistant.components.plex.PlexWebsocket.listen"
+ ) as mock_listen:
+ entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert mock_listen.called
+
+ assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1
+ assert entry.state == ENTRY_STATE_LOADED
+
+ server_id = mock_plex_server.machineIdentifier
+ loaded_server = hass.data[const.DOMAIN][const.SERVERS][server_id]
+
+ assert loaded_server.plex_server == mock_plex_server
+
+ assert server_id in hass.data[const.DOMAIN][const.DISPATCHERS]
+ assert server_id in hass.data[const.DOMAIN][const.WEBSOCKETS]
+ assert (
+ 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()
+
+ 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()
+
+ with patch.object(
+ mock_plex_server, "clients", side_effect=plexapi.exceptions.BadRequest
+ ):
+ 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=requests.exceptions.RequestException
+ ):
+ async_dispatcher_send(
+ hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)
+ )
+ await hass.async_block_till_done()
+
+
+async def test_set_config_entry_unique_id(hass):
+ """Test updating missing unique_id from config entry."""
+
+ mock_plex_server = MockPlexServer()
+
+ entry = MockConfigEntry(
+ domain=const.DOMAIN, data=DEFAULT_DATA, options=DEFAULT_OPTIONS, unique_id=None,
+ )
+
+ with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
+ "homeassistant.components.plex.PlexWebsocket.listen"
+ ) as mock_listen:
+ entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert mock_listen.called
+
+ assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1
+ assert entry.state == ENTRY_STATE_LOADED
+
+ assert (
+ hass.config_entries.async_entries(const.DOMAIN)[0].unique_id
+ == mock_plex_server.machineIdentifier
+ )
+
+
+async def test_setup_config_entry_with_error(hass):
+ """Test setup component from config entry with errors."""
+
+ entry = MockConfigEntry(
+ domain=const.DOMAIN,
+ data=DEFAULT_DATA,
+ options=DEFAULT_OPTIONS,
+ unique_id=DEFAULT_DATA["server_id"],
+ )
+
+ with patch(
+ "homeassistant.components.plex.PlexServer.connect",
+ side_effect=requests.exceptions.ConnectionError,
+ ):
+ entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(entry.entry_id) is False
+ await hass.async_block_till_done()
+
+ assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1
+ assert entry.state == ENTRY_STATE_SETUP_RETRY
+
+ with patch(
+ "homeassistant.components.plex.PlexServer.connect",
+ side_effect=plexapi.exceptions.BadRequest,
+ ):
+ next_update = dt_util.utcnow() + timedelta(seconds=30)
+ async_fire_time_changed(hass, next_update)
+ await hass.async_block_till_done()
+
+ assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1
+ assert entry.state == ENTRY_STATE_SETUP_ERROR
+
+
+async def test_setup_with_insecure_config_entry(hass):
+ """Test setup component with config."""
+
+ mock_plex_server = MockPlexServer()
+
+ INSECURE_DATA = copy.deepcopy(DEFAULT_DATA)
+ INSECURE_DATA[const.PLEX_SERVER_CONFIG][CONF_VERIFY_SSL] = False
+
+ entry = MockConfigEntry(
+ domain=const.DOMAIN,
+ data=INSECURE_DATA,
+ options=DEFAULT_OPTIONS,
+ unique_id=DEFAULT_DATA["server_id"],
+ )
+
+ with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
+ "homeassistant.components.plex.PlexWebsocket.listen"
+ ) as mock_listen:
+ entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert mock_listen.called
+
+ assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1
+ assert entry.state == ENTRY_STATE_LOADED
+
+
+async def test_unload_config_entry(hass):
+ """Test unloading a config entry."""
+ mock_plex_server = MockPlexServer()
+
+ entry = MockConfigEntry(
+ domain=const.DOMAIN,
+ data=DEFAULT_DATA,
+ options=DEFAULT_OPTIONS,
+ unique_id=DEFAULT_DATA["server_id"],
+ )
+ entry.add_to_hass(hass)
+
+ config_entries = hass.config_entries.async_entries(const.DOMAIN)
+ assert len(config_entries) == 1
+ assert entry is config_entries[0]
+
+ with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
+ "homeassistant.components.plex.PlexWebsocket.listen"
+ ) as mock_listen:
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+ assert mock_listen.called
+
+ assert entry.state == ENTRY_STATE_LOADED
+
+ server_id = mock_plex_server.machineIdentifier
+ loaded_server = hass.data[const.DOMAIN][const.SERVERS][server_id]
+
+ assert loaded_server.plex_server == mock_plex_server
+
+ assert server_id in hass.data[const.DOMAIN][const.DISPATCHERS]
+ assert server_id in hass.data[const.DOMAIN][const.WEBSOCKETS]
+ assert (
+ hass.data[const.DOMAIN][const.PLATFORMS_COMPLETED][server_id] == const.PLATFORMS
+ )
+
+ with patch("homeassistant.components.plex.PlexWebsocket.close") as mock_close:
+ await hass.config_entries.async_unload(entry.entry_id)
+ assert mock_close.called
+
+ assert entry.state == ENTRY_STATE_NOT_LOADED
+
+ assert server_id not in hass.data[const.DOMAIN][const.SERVERS]
+ assert server_id not in hass.data[const.DOMAIN][const.DISPATCHERS]
+ assert server_id not in hass.data[const.DOMAIN][const.WEBSOCKETS]
+
+
+async def test_setup_with_photo_session(hass):
+ """Test setup component with config."""
+
+ mock_plex_server = MockPlexServer(session_type="photo")
+
+ entry = MockConfigEntry(
+ domain=const.DOMAIN,
+ data=DEFAULT_DATA,
+ options=DEFAULT_OPTIONS,
+ unique_id=DEFAULT_DATA["server_id"],
+ )
+
+ 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()
+
+ assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1
+ assert entry.state == ENTRY_STATE_LOADED
+
+ server_id = mock_plex_server.machineIdentifier
+
+ async_dispatcher_send(hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
+ await hass.async_block_till_done()
+
+ 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))
diff --git a/tests/components/plex/test_server.py b/tests/components/plex/test_server.py
new file mode 100644
index 00000000000..646a6ded32e
--- /dev/null
+++ b/tests/components/plex/test_server.py
@@ -0,0 +1,134 @@
+"""Tests for Plex server."""
+import copy
+
+from asynctest import patch
+
+from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
+from homeassistant.components.plex.const import (
+ CONF_IGNORE_NEW_SHARED_USERS,
+ CONF_MONITORED_USERS,
+ DOMAIN,
+ PLEX_UPDATE_PLATFORMS_SIGNAL,
+ SERVERS,
+)
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+from .const import DEFAULT_DATA, DEFAULT_OPTIONS
+from .mock_classes import MockPlexServer
+
+from tests.common import MockConfigEntry
+
+
+async def test_new_users_available(hass):
+ """Test setting up when new users available on Plex server."""
+
+ MONITORED_USERS = {"Owner": {"enabled": True}}
+ OPTIONS_WITH_USERS = copy.deepcopy(DEFAULT_OPTIONS)
+ OPTIONS_WITH_USERS[MP_DOMAIN][CONF_MONITORED_USERS] = MONITORED_USERS
+
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=DEFAULT_DATA,
+ options=OPTIONS_WITH_USERS,
+ 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
+
+ async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
+ await hass.async_block_till_done()
+
+ monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users
+
+ ignored_users = [x for x in monitored_users if not monitored_users[x]["enabled"]]
+ assert len(monitored_users) == 1
+ assert len(ignored_users) == 0
+
+ sensor = hass.states.get("sensor.plex_plex_server_1")
+ assert sensor.state == str(len(mock_plex_server.accounts))
+
+
+async def test_new_ignored_users_available(hass, caplog):
+ """Test setting up when new users available on Plex server but are ignored."""
+
+ MONITORED_USERS = {"Owner": {"enabled": True}}
+ OPTIONS_WITH_USERS = copy.deepcopy(DEFAULT_OPTIONS)
+ OPTIONS_WITH_USERS[MP_DOMAIN][CONF_MONITORED_USERS] = MONITORED_USERS
+ OPTIONS_WITH_USERS[MP_DOMAIN][CONF_IGNORE_NEW_SHARED_USERS] = True
+
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=DEFAULT_DATA,
+ options=OPTIONS_WITH_USERS,
+ 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
+
+ async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
+ await hass.async_block_till_done()
+
+ monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users
+
+ ignored_users = [x for x in mock_plex_server.accounts if x not in monitored_users]
+ 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
+
+ sensor = hass.states.get("sensor.plex_plex_server_1")
+ assert sensor.state == str(len(mock_plex_server.accounts))
+
+
+async def test_mark_sessions_idle(hass):
+ """Test marking media_players as idle when sessions end."""
+ 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
+
+ async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
+ await hass.async_block_till_done()
+
+ sensor = hass.states.get("sensor.plex_plex_server_1")
+ assert sensor.state == str(len(mock_plex_server.accounts))
+
+ 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()
+
+ sensor = hass.states.get("sensor.plex_plex_server_1")
+ assert sensor.state == "0"
diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py
index 5c6189a811e..a8bc9fe9823 100644
--- a/tests/components/prometheus/test_init.py
+++ b/tests/components/prometheus/test_init.py
@@ -5,7 +5,11 @@ from homeassistant import setup
from homeassistant.components import climate, sensor
from homeassistant.components.demo.sensor import DemoSensor
import homeassistant.components.prometheus as prometheus
-from homeassistant.const import DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR
+from homeassistant.const import (
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ DEVICE_CLASS_POWER,
+ ENERGY_KILO_WATT_HOUR,
+)
from homeassistant.setup import async_setup_component
@@ -47,7 +51,12 @@ async def prometheus_client(loop, hass, hass_client):
await sensor4.async_update_ha_state()
sensor5 = DemoSensor(
- None, "SPS30 PM <1µm Weight concentration", 3.7069, None, "µg/m³", None
+ None,
+ "SPS30 PM <1µm Weight concentration",
+ 3.7069,
+ None,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ None,
)
sensor5.hass = hass
sensor5.entity_id = "sensor.sps30_pm_1um_weight_concentration"
diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py
index 9e43f647301..38dafdda986 100644
--- a/tests/components/rainmachine/test_config_flow.py
+++ b/tests/components/rainmachine/test_config_flow.py
@@ -5,6 +5,7 @@ from regenmaschine.errors import RainMachineError
from homeassistant import data_entry_flow
from homeassistant.components.rainmachine import DOMAIN, config_flow
+from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import (
CONF_IP_ADDRESS,
CONF_PASSWORD,
@@ -25,12 +26,15 @@ async def test_duplicate_error(hass):
CONF_SSL: True,
}
- MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
- flow = config_flow.RainMachineFlowHandler()
- flow.hass = hass
+ MockConfigEntry(domain=DOMAIN, unique_id="192.168.1.100", data=conf).add_to_hass(
+ hass
+ )
- result = await flow.async_step_user(user_input=conf)
- assert result["errors"] == {CONF_IP_ADDRESS: "identifier_exists"}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=conf
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
async def test_invalid_password(hass):
@@ -44,6 +48,7 @@ async def test_invalid_password(hass):
flow = config_flow.RainMachineFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
with patch(
"homeassistant.components.rainmachine.config_flow.login",
@@ -57,6 +62,7 @@ async def test_show_form(hass):
"""Test that the form is served with no input."""
flow = config_flow.RainMachineFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=None)
@@ -71,10 +77,12 @@ async def test_step_import(hass):
CONF_PASSWORD: "password",
CONF_PORT: 8080,
CONF_SSL: True,
+ CONF_SCAN_INTERVAL: 60,
}
flow = config_flow.RainMachineFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
with patch(
"homeassistant.components.rainmachine.config_flow.login",
@@ -100,10 +108,12 @@ async def test_step_user(hass):
CONF_PASSWORD: "password",
CONF_PORT: 8080,
CONF_SSL: True,
+ CONF_SCAN_INTERVAL: 60,
}
flow = config_flow.RainMachineFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
with patch(
"homeassistant.components.rainmachine.config_flow.login",
diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py
new file mode 100644
index 00000000000..07bfcb0bf4f
--- /dev/null
+++ b/tests/components/recorder/common.py
@@ -0,0 +1,22 @@
+"""Common test utils for working with recorder."""
+
+from homeassistant.components import recorder
+from homeassistant.util import dt as dt_util
+
+from tests.common import fire_time_changed
+
+DB_COMMIT_INTERVAL = 50
+
+
+def wait_recording_done(hass):
+ """Block till recording is done."""
+ trigger_db_commit(hass)
+ hass.block_till_done()
+ hass.data[recorder.DATA_INSTANCE].block_till_done()
+
+
+def trigger_db_commit(hass):
+ """Force the recorder to commit."""
+ for _ in range(DB_COMMIT_INTERVAL):
+ # We only commit on time change
+ fire_time_changed(hass, dt_util.utcnow())
diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py
index a21ef578ca9..8a56ba3d977 100644
--- a/tests/components/recorder/test_init.py
+++ b/tests/components/recorder/test_init.py
@@ -13,6 +13,8 @@ from homeassistant.const import MATCH_ALL
from homeassistant.core import callback
from homeassistant.setup import async_setup_component
+from .common import wait_recording_done
+
from tests.common import get_test_home_assistant, init_recorder_component
@@ -37,8 +39,7 @@ class TestRecorder(unittest.TestCase):
self.hass.states.set(entity_id, state, attributes)
- self.hass.block_till_done()
- self.hass.data[DATA_INSTANCE].block_till_done()
+ wait_recording_done(self.hass)
with session_scope(hass=self.hass) as session:
db_states = list(session.query(States))
@@ -65,7 +66,7 @@ class TestRecorder(unittest.TestCase):
self.hass.bus.fire(event_type, event_data)
- self.hass.block_till_done()
+ wait_recording_done(self.hass)
assert len(events) == 1
event = events[0]
@@ -109,8 +110,7 @@ def _add_entities(hass, entity_ids):
attributes = {"test_attr": 5, "test_attr_10": "nice"}
for idx, entity_id in enumerate(entity_ids):
hass.states.set(entity_id, "state{}".format(idx), attributes)
- hass.block_till_done()
- hass.data[DATA_INSTANCE].block_till_done()
+ wait_recording_done(hass)
with session_scope(hass=hass) as session:
return [st.to_native() for st in session.query(States)]
@@ -121,8 +121,7 @@ def _add_events(hass, events):
session.query(Events).delete(synchronize_session=False)
for event_type in events:
hass.bus.fire(event_type)
- hass.block_till_done()
- hass.data[DATA_INSTANCE].block_till_done()
+ wait_recording_done(hass)
with session_scope(hass=hass) as session:
return [ev.to_native() for ev in session.query(Events)]
@@ -201,6 +200,7 @@ def test_recorder_setup_failure():
hass,
keep_days=7,
purge_interval=2,
+ commit_interval=1,
uri="sqlite://",
db_max_retries=10,
db_retry_wait=3,
diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py
index 5018418f493..cd2a911292c 100644
--- a/tests/components/rest/test_sensor.py
+++ b/tests/components/rest/test_sensor.py
@@ -559,6 +559,39 @@ class TestRestSensor(unittest.TestCase):
assert "12556" == self.sensor.device_state_attributes["ver"]
assert "bogus" == self.sensor.state
+ def test_update_with_application_xml_convert_json_attrs_with_jsonattr_template(
+ self,
+ ):
+ """Test attributes get extracted from a JSON result that was converted from XML with application/xml mime type."""
+ json_attrs_path = "$.main"
+ value_template = template("{{ value_json.main.dog }}")
+ value_template.hass = self.hass
+
+ self.rest.update = Mock(
+ "rest.RestData.update",
+ side_effect=self.update_side_effect(
+ "13",
+ CaseInsensitiveDict({"Content-Type": "application/xml"}),
+ ),
+ )
+ self.sensor = rest.RestSensor(
+ self.hass,
+ self.rest,
+ self.name,
+ self.unit_of_measurement,
+ self.device_class,
+ value_template,
+ ["dog", "cat"],
+ self.force_update,
+ self.resource_template,
+ json_attrs_path,
+ )
+
+ self.sensor.update()
+ assert "3" == self.sensor.device_state_attributes["cat"]
+ assert "1" == self.sensor.device_state_attributes["dog"]
+ assert "1" == self.sensor.state
+
@patch("homeassistant.components.rest.sensor._LOGGER")
def test_update_with_xml_convert_bad_xml(self, mock_logger):
"""Test attributes get extracted from a XML result with bad xml."""
@@ -639,7 +672,7 @@ class TestRestData(unittest.TestCase):
self.rest.update()
assert "test data" == self.rest.data
- @patch("requests.request", side_effect=RequestException)
+ @patch("requests.Session.request", side_effect=RequestException)
def test_update_request_exception(self, mock_req):
"""Test update when a request exception occurs."""
self.rest.update()
diff --git a/tests/components/rflink/test_sensor.py b/tests/components/rflink/test_sensor.py
index b68e1f959f1..a4904f5bc9b 100644
--- a/tests/components/rflink/test_sensor.py
+++ b/tests/components/rflink/test_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.rflink import (
EVENT_KEY_SENSOR,
TMP_ENTITY,
)
-from homeassistant.const import STATE_UNKNOWN
+from homeassistant.const import STATE_UNKNOWN, UNIT_PERCENTAGE
from tests.components.rflink.test_init import mock_rflink
@@ -141,7 +141,12 @@ async def test_aliases(hass, monkeypatch):
# test event for config sensor
event_callback(
- {"id": "test_alias_02_0", "sensor": "humidity", "value": 65, "unit": "%"}
+ {
+ "id": "test_alias_02_0",
+ "sensor": "humidity",
+ "value": 65,
+ "unit": UNIT_PERCENTAGE,
+ }
)
await hass.async_block_till_done()
@@ -149,7 +154,7 @@ async def test_aliases(hass, monkeypatch):
updated_sensor = hass.states.get("sensor.test_02")
assert updated_sensor
assert updated_sensor.state == "65"
- assert updated_sensor.attributes["unit_of_measurement"] == "%"
+ assert updated_sensor.attributes["unit_of_measurement"] == UNIT_PERCENTAGE
async def test_race_condition(hass, monkeypatch):
diff --git a/tests/components/rfxtrx/test_sensor.py b/tests/components/rfxtrx/test_sensor.py
index 652c823e0cf..e258ebb9aa1 100644
--- a/tests/components/rfxtrx/test_sensor.py
+++ b/tests/components/rfxtrx/test_sensor.py
@@ -4,7 +4,7 @@ import unittest
import pytest
from homeassistant.components import rfxtrx as rfxtrx_core
-from homeassistant.const import TEMP_CELSIUS
+from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE
from homeassistant.setup import setup_component
from tests.common import get_test_home_assistant, mock_component
@@ -137,7 +137,7 @@ class TestSensorRfxtrx(unittest.TestCase):
assert len(rfxtrx_core.RFX_DEVICES[id]) == 2
_entity_temp = rfxtrx_core.RFX_DEVICES[id]["Temperature"]
_entity_hum = rfxtrx_core.RFX_DEVICES[id]["Humidity"]
- assert "%" == _entity_hum.unit_of_measurement
+ assert UNIT_PERCENTAGE == _entity_hum.unit_of_measurement
assert "Bath" == _entity_hum.__str__()
assert _entity_hum.state is None
assert TEMP_CELSIUS == _entity_temp.unit_of_measurement
@@ -271,7 +271,7 @@ class TestSensorRfxtrx(unittest.TestCase):
assert len(rfxtrx_core.RFX_DEVICES[id]) == 2
_entity_temp = rfxtrx_core.RFX_DEVICES[id]["Temperature"]
_entity_hum = rfxtrx_core.RFX_DEVICES[id]["Humidity"]
- assert "%" == _entity_hum.unit_of_measurement
+ assert UNIT_PERCENTAGE == _entity_hum.unit_of_measurement
assert "Bath" == _entity_hum.__str__()
assert _entity_temp.state is None
assert TEMP_CELSIUS == _entity_temp.unit_of_measurement
@@ -303,7 +303,7 @@ class TestSensorRfxtrx(unittest.TestCase):
assert len(rfxtrx_core.RFX_DEVICES[id]) == 2
_entity_temp = rfxtrx_core.RFX_DEVICES[id]["Temperature"]
_entity_hum = rfxtrx_core.RFX_DEVICES[id]["Humidity"]
- assert "%" == _entity_hum.unit_of_measurement
+ assert UNIT_PERCENTAGE == _entity_hum.unit_of_measurement
assert 15 == _entity_hum.state
assert {
"Battery numeric": 9,
diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py
index 91ee8a7205f..5485ee95827 100644
--- a/tests/components/samsungtv/test_config_flow.py
+++ b/tests/components/samsungtv/test_config_flow.py
@@ -4,6 +4,7 @@ from unittest.mock import call, patch
from asynctest import mock
import pytest
from samsungctl.exceptions import AccessDenied, UnhandledResponse
+from samsungtvws.exceptions import ConnectionFailure
from websocket import WebSocketProtocolException
from homeassistant.components.samsungtv.const import (
@@ -36,15 +37,6 @@ MOCK_SSDP_DATA_NOPREFIX = {
ATTR_UPNP_UDN: "fake2_uuid",
}
-AUTODETECT_WEBSOCKET = {
- "name": "HomeAssistant",
- "description": "HomeAssistant",
- "id": "ha.component.samsung",
- "method": "websocket",
- "port": None,
- "host": "fake_host",
- "timeout": 1,
-}
AUTODETECT_LEGACY = {
"name": "HomeAssistant",
"description": "HomeAssistant",
@@ -59,7 +51,9 @@ AUTODETECT_LEGACY = {
@pytest.fixture(name="remote")
def remote_fixture():
"""Patch the samsungctl Remote."""
- with patch("samsungctl.Remote") as remote_class, patch(
+ with patch(
+ "homeassistant.components.samsungtv.bridge.Remote"
+ ) as remote_class, patch(
"homeassistant.components.samsungtv.config_flow.socket"
) as socket_class:
remote = mock.Mock()
@@ -71,9 +65,25 @@ def remote_fixture():
yield remote
-async def test_user(hass, remote):
- """Test starting a flow by user."""
+@pytest.fixture(name="remotews")
+def remotews_fixture():
+ """Patch the samsungtvws SamsungTVWS."""
+ with patch(
+ "homeassistant.components.samsungtv.bridge.SamsungTVWS"
+ ) as remotews_class, patch(
+ "homeassistant.components.samsungtv.config_flow.socket"
+ ) as socket_class:
+ remotews = mock.Mock()
+ remotews.__enter__ = mock.Mock()
+ remotews.__exit__ = mock.Mock()
+ remotews_class.return_value = remotews
+ socket = mock.Mock()
+ socket_class.return_value = socket
+ yield remotews
+
+async def test_user_legacy(hass, remote):
+ """Test starting a flow by user."""
# show form
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}
@@ -85,23 +95,51 @@ async def test_user(hass, remote):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_USER_DATA
)
+ # legacy tv entry created
assert result["type"] == "create_entry"
assert result["title"] == "fake_name"
assert result["data"][CONF_HOST] == "fake_host"
assert result["data"][CONF_NAME] == "fake_name"
+ assert result["data"][CONF_METHOD] == "legacy"
assert result["data"][CONF_MANUFACTURER] is None
assert result["data"][CONF_MODEL] is None
assert result["data"][CONF_ID] is None
-async def test_user_missing_auth(hass):
+async def test_user_websocket(hass, remotews):
+ """Test starting a flow by user."""
+ with patch(
+ "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom")
+ ):
+ # show form
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "user"
+
+ # entry was added
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=MOCK_USER_DATA
+ )
+ # legacy tv entry created
+ assert result["type"] == "create_entry"
+ assert result["title"] == "fake_name"
+ assert result["data"][CONF_HOST] == "fake_host"
+ assert result["data"][CONF_NAME] == "fake_name"
+ assert result["data"][CONF_METHOD] == "websocket"
+ assert result["data"][CONF_MANUFACTURER] is None
+ assert result["data"][CONF_MODEL] is None
+ assert result["data"][CONF_ID] is None
+
+
+async def test_user_legacy_missing_auth(hass):
"""Test starting a flow by user with authentication."""
with patch(
- "homeassistant.components.samsungtv.config_flow.Remote",
+ "homeassistant.components.samsungtv.bridge.Remote",
side_effect=AccessDenied("Boom"),
), patch("homeassistant.components.samsungtv.config_flow.socket"):
-
- # missing authentication
+ # legacy device missing authentication
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA
)
@@ -109,14 +147,31 @@ async def test_user_missing_auth(hass):
assert result["reason"] == "auth_missing"
-async def test_user_not_supported(hass):
+async def test_user_legacy_not_supported(hass):
"""Test starting a flow by user for not supported device."""
with patch(
- "homeassistant.components.samsungtv.config_flow.Remote",
+ "homeassistant.components.samsungtv.bridge.Remote",
side_effect=UnhandledResponse("Boom"),
), patch("homeassistant.components.samsungtv.config_flow.socket"):
+ # legacy device not supported
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "not_supported"
- # device not supported
+
+async def test_user_websocket_not_supported(hass):
+ """Test starting a flow by user for not supported device."""
+ with patch(
+ "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ ), patch(
+ "homeassistant.components.samsungtv.bridge.SamsungTVWS",
+ side_effect=WebSocketProtocolException("Boom"),
+ ), patch(
+ "homeassistant.components.samsungtv.config_flow.socket"
+ ):
+ # websocket device not supported
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA
)
@@ -127,11 +182,30 @@ async def test_user_not_supported(hass):
async def test_user_not_successful(hass):
"""Test starting a flow by user but no connection found."""
with patch(
- "homeassistant.components.samsungtv.config_flow.Remote",
+ "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ ), patch(
+ "homeassistant.components.samsungtv.bridge.SamsungTVWS",
side_effect=OSError("Boom"),
- ), patch("homeassistant.components.samsungtv.config_flow.socket"):
+ ), patch(
+ "homeassistant.components.samsungtv.config_flow.socket"
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "not_successful"
- # device not connectable
+
+async def test_user_not_successful_2(hass):
+ """Test starting a flow by user but no connection found."""
+ with patch(
+ "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ ), patch(
+ "homeassistant.components.samsungtv.bridge.SamsungTVWS",
+ side_effect=ConnectionFailure("Boom"),
+ ), patch(
+ "homeassistant.components.samsungtv.config_flow.socket"
+ ):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA
)
@@ -202,10 +276,10 @@ async def test_ssdp_noprefix(hass, remote):
assert result["data"][CONF_ID] == "fake2_uuid"
-async def test_ssdp_missing_auth(hass):
+async def test_ssdp_legacy_missing_auth(hass):
"""Test starting a flow from discovery with authentication."""
with patch(
- "homeassistant.components.samsungtv.config_flow.Remote",
+ "homeassistant.components.samsungtv.bridge.Remote",
side_effect=AccessDenied("Boom"),
), patch("homeassistant.components.samsungtv.config_flow.socket"):
@@ -224,10 +298,10 @@ async def test_ssdp_missing_auth(hass):
assert result["reason"] == "auth_missing"
-async def test_ssdp_not_supported(hass):
+async def test_ssdp_legacy_not_supported(hass):
"""Test starting a flow from discovery for not supported device."""
with patch(
- "homeassistant.components.samsungtv.config_flow.Remote",
+ "homeassistant.components.samsungtv.bridge.Remote",
side_effect=UnhandledResponse("Boom"),
), patch("homeassistant.components.samsungtv.config_flow.socket"):
@@ -246,13 +320,16 @@ async def test_ssdp_not_supported(hass):
assert result["reason"] == "not_supported"
-async def test_ssdp_not_supported_2(hass):
+async def test_ssdp_websocket_not_supported(hass):
"""Test starting a flow from discovery for not supported device."""
with patch(
- "homeassistant.components.samsungtv.config_flow.Remote",
+ "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ ), patch(
+ "homeassistant.components.samsungtv.bridge.SamsungTVWS",
side_effect=WebSocketProtocolException("Boom"),
- ), patch("homeassistant.components.samsungtv.config_flow.socket"):
-
+ ), patch(
+ "homeassistant.components.samsungtv.config_flow.socket"
+ ):
# confirm to add the entry
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA
@@ -271,9 +348,39 @@ async def test_ssdp_not_supported_2(hass):
async def test_ssdp_not_successful(hass):
"""Test starting a flow from discovery but no device found."""
with patch(
- "homeassistant.components.samsungtv.config_flow.Remote",
+ "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ ), patch(
+ "homeassistant.components.samsungtv.bridge.SamsungTVWS",
side_effect=OSError("Boom"),
- ), patch("homeassistant.components.samsungtv.config_flow.socket"):
+ ), patch(
+ "homeassistant.components.samsungtv.config_flow.socket"
+ ):
+
+ # confirm to add the entry
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "confirm"
+
+ # device not found
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input="whatever"
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "not_successful"
+
+
+async def test_ssdp_not_successful_2(hass):
+ """Test starting a flow from discovery but no device found."""
+ with patch(
+ "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ ), patch(
+ "homeassistant.components.samsungtv.bridge.SamsungTVWS",
+ side_effect=ConnectionFailure("Boom"),
+ ), patch(
+ "homeassistant.components.samsungtv.config_flow.socket"
+ ):
# confirm to add the entry
result = await hass.config_entries.flow.async_init(
@@ -316,9 +423,10 @@ async def test_ssdp_already_configured(hass, remote):
DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA
)
assert result["type"] == "create_entry"
- assert result["data"][CONF_MANUFACTURER] is None
- assert result["data"][CONF_MODEL] is None
- assert result["data"][CONF_ID] is None
+ entry = result["result"]
+ assert entry.data[CONF_MANUFACTURER] is None
+ assert entry.data[CONF_MODEL] is None
+ assert entry.data[CONF_ID] is None
# failed as already configured
result2 = await hass.config_entries.flow.async_init(
@@ -328,27 +436,37 @@ async def test_ssdp_already_configured(hass, remote):
assert result2["reason"] == "already_configured"
# check updated device info
- assert result["data"][CONF_MANUFACTURER] == "fake_manufacturer"
- assert result["data"][CONF_MODEL] == "fake_model"
- assert result["data"][CONF_ID] == "fake_uuid"
+ assert entry.data[CONF_MANUFACTURER] == "fake_manufacturer"
+ assert entry.data[CONF_MODEL] == "fake_model"
+ assert entry.data[CONF_ID] == "fake_uuid"
-async def test_autodetect_websocket(hass, remote):
+async def test_autodetect_websocket(hass, remote, remotews):
"""Test for send key with autodetection of protocol."""
- with patch("homeassistant.components.samsungtv.config_flow.Remote") as remote:
+ with patch(
+ "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ ), patch("homeassistant.components.samsungtv.bridge.SamsungTVWS") as remotews:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA
)
assert result["type"] == "create_entry"
assert result["data"][CONF_METHOD] == "websocket"
- assert remote.call_count == 1
- assert remote.call_args_list == [call(AUTODETECT_WEBSOCKET)]
+ assert remotews.call_count == 1
+ assert remotews.call_args_list == [
+ call(
+ host="fake_host",
+ name="HomeAssistant",
+ port=8001,
+ timeout=31,
+ token=None,
+ )
+ ]
async def test_autodetect_auth_missing(hass, remote):
"""Test for send key with autodetection of protocol."""
with patch(
- "homeassistant.components.samsungtv.config_flow.Remote",
+ "homeassistant.components.samsungtv.bridge.Remote",
side_effect=[AccessDenied("Boom")],
) as remote:
result = await hass.config_entries.flow.async_init(
@@ -357,13 +475,13 @@ async def test_autodetect_auth_missing(hass, remote):
assert result["type"] == "abort"
assert result["reason"] == "auth_missing"
assert remote.call_count == 1
- assert remote.call_args_list == [call(AUTODETECT_WEBSOCKET)]
+ assert remote.call_args_list == [call(AUTODETECT_LEGACY)]
async def test_autodetect_not_supported(hass, remote):
"""Test for send key with autodetection of protocol."""
with patch(
- "homeassistant.components.samsungtv.config_flow.Remote",
+ "homeassistant.components.samsungtv.bridge.Remote",
side_effect=[UnhandledResponse("Boom")],
) as remote:
result = await hass.config_entries.flow.async_init(
@@ -372,40 +490,52 @@ async def test_autodetect_not_supported(hass, remote):
assert result["type"] == "abort"
assert result["reason"] == "not_supported"
assert remote.call_count == 1
- assert remote.call_args_list == [call(AUTODETECT_WEBSOCKET)]
+ assert remote.call_args_list == [call(AUTODETECT_LEGACY)]
async def test_autodetect_legacy(hass, remote):
"""Test for send key with autodetection of protocol."""
- with patch(
- "homeassistant.components.samsungtv.config_flow.Remote",
- side_effect=[OSError("Boom"), mock.DEFAULT],
- ) as remote:
+ with patch("homeassistant.components.samsungtv.bridge.Remote") as remote:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA
)
assert result["type"] == "create_entry"
assert result["data"][CONF_METHOD] == "legacy"
- assert remote.call_count == 2
- assert remote.call_args_list == [
- call(AUTODETECT_WEBSOCKET),
- call(AUTODETECT_LEGACY),
- ]
+ assert remote.call_count == 1
+ assert remote.call_args_list == [call(AUTODETECT_LEGACY)]
-async def test_autodetect_none(hass, remote):
+async def test_autodetect_none(hass, remote, remotews):
"""Test for send key with autodetection of protocol."""
with patch(
- "homeassistant.components.samsungtv.config_flow.Remote",
+ "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ ) as remote, patch(
+ "homeassistant.components.samsungtv.bridge.SamsungTVWS",
side_effect=OSError("Boom"),
- ) as remote:
+ ) as remotews:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA
)
assert result["type"] == "abort"
assert result["reason"] == "not_successful"
- assert remote.call_count == 2
+ assert remote.call_count == 1
assert remote.call_args_list == [
- call(AUTODETECT_WEBSOCKET),
call(AUTODETECT_LEGACY),
]
+ assert remotews.call_count == 2
+ assert remotews.call_args_list == [
+ call(
+ host="fake_host",
+ name="HomeAssistant",
+ port=8001,
+ timeout=31,
+ token=None,
+ ),
+ call(
+ host="fake_host",
+ name="HomeAssistant",
+ port=8002,
+ timeout=31,
+ token=None,
+ ),
+ ]
diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py
index cd31434e6b0..232a04416d5 100644
--- a/tests/components/samsungtv/test_init.py
+++ b/tests/components/samsungtv/test_init.py
@@ -1,6 +1,6 @@
"""Tests for the Samsung TV Integration."""
-from unittest.mock import call, patch
-
+from asynctest import mock
+from asynctest.mock import call, patch
import pytest
from homeassistant.components.media_player.const import DOMAIN, SUPPORT_TURN_ON
@@ -14,7 +14,6 @@ from homeassistant.const import (
ATTR_SUPPORTED_FEATURES,
CONF_HOST,
CONF_NAME,
- CONF_PORT,
SERVICE_VOLUME_UP,
)
from homeassistant.setup import async_setup_component
@@ -25,7 +24,6 @@ MOCK_CONFIG = {
{
CONF_HOST: "fake_host",
CONF_NAME: "fake_name",
- CONF_PORT: 1234,
CONF_ON_ACTION: [{"delay": "00:00:01"}],
}
]
@@ -34,9 +32,9 @@ REMOTE_CALL = {
"name": "HomeAssistant",
"description": "HomeAssistant",
"id": "ha.component.samsung",
- "method": "websocket",
- "port": MOCK_CONFIG[SAMSUNGTV_DOMAIN][0][CONF_PORT],
+ "method": "legacy",
"host": MOCK_CONFIG[SAMSUNGTV_DOMAIN][0][CONF_HOST],
+ "port": None,
"timeout": 1,
}
@@ -44,11 +42,17 @@ REMOTE_CALL = {
@pytest.fixture(name="remote")
def remote_fixture():
"""Patch the samsungctl Remote."""
- with patch("homeassistant.components.samsungtv.socket") as socket1, patch(
+ with patch(
+ "homeassistant.components.samsungtv.bridge.Remote"
+ ) as remote_class, patch(
"homeassistant.components.samsungtv.config_flow.socket"
- ) as socket2, patch("homeassistant.components.samsungtv.config_flow.Remote"), patch(
- "homeassistant.components.samsungtv.media_player.SamsungRemote"
- ) as remote:
+ ) as socket1, patch(
+ "homeassistant.components.samsungtv.socket"
+ ) as socket2:
+ remote = mock.Mock()
+ remote.__enter__ = mock.Mock()
+ remote.__exit__ = mock.Mock()
+ remote_class.return_value = remote
socket1.gethostbyname.return_value = "FAKE_IP_ADDRESS"
socket2.gethostbyname.return_value = "FAKE_IP_ADDRESS"
yield remote
@@ -56,22 +60,24 @@ def remote_fixture():
async def test_setup(hass, remote):
"""Test Samsung TV integration is setup."""
- await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG)
- await hass.async_block_till_done()
- state = hass.states.get(ENTITY_ID)
+ with patch("homeassistant.components.samsungtv.bridge.Remote") as remote:
+ await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG)
+ await hass.async_block_till_done()
+ state = hass.states.get(ENTITY_ID)
- # test name and turn_on
- assert state
- assert state.name == "fake_name"
- assert (
- state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_SAMSUNGTV | SUPPORT_TURN_ON
- )
+ # test name and turn_on
+ assert state
+ assert state.name == "fake_name"
+ assert (
+ state.attributes[ATTR_SUPPORTED_FEATURES]
+ == SUPPORT_SAMSUNGTV | SUPPORT_TURN_ON
+ )
- # test host and port
- assert await hass.services.async_call(
- DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True
- )
- assert remote.mock_calls[0] == call(REMOTE_CALL)
+ # test host and port
+ assert await hass.services.async_call(
+ DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True
+ )
+ assert remote.call_args == call(REMOTE_CALL)
async def test_setup_duplicate_config(hass, remote, caplog):
diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py
index ba245ce7d6f..dff7525d980 100644
--- a/tests/components/samsungtv/test_media_player.py
+++ b/tests/components/samsungtv/test_media_player.py
@@ -7,6 +7,7 @@ from asynctest import mock
from asynctest.mock import call, patch
import pytest
from samsungctl import exceptions
+from samsungtvws.exceptions import ConnectionFailure
from websocket import WebSocketException
from homeassistant.components.media_player import DEVICE_CLASS_TV
@@ -54,6 +55,17 @@ from tests.common import async_fire_time_changed
ENTITY_ID = f"{DOMAIN}.fake"
MOCK_CONFIG = {
+ SAMSUNGTV_DOMAIN: [
+ {
+ CONF_HOST: "fake",
+ CONF_NAME: "fake",
+ CONF_PORT: 55000,
+ CONF_ON_ACTION: [{"delay": "00:00:01"}],
+ }
+ ]
+}
+
+MOCK_CONFIGWS = {
SAMSUNGTV_DOMAIN: [
{
CONF_HOST: "fake",
@@ -75,14 +87,35 @@ MOCK_CONFIG_NOTURNON = {
@pytest.fixture(name="remote")
def remote_fixture():
"""Patch the samsungctl Remote."""
- with patch("homeassistant.components.samsungtv.config_flow.Remote"), patch(
+ with patch(
+ "homeassistant.components.samsungtv.bridge.Remote"
+ ) as remote_class, patch(
"homeassistant.components.samsungtv.config_flow.socket"
) as socket1, patch(
- "homeassistant.components.samsungtv.media_player.SamsungRemote"
- ) as remote_class, patch(
"homeassistant.components.samsungtv.socket"
) as socket2:
remote = mock.Mock()
+ remote.__enter__ = mock.Mock()
+ remote.__exit__ = mock.Mock()
+ remote_class.return_value = remote
+ socket1.gethostbyname.return_value = "FAKE_IP_ADDRESS"
+ socket2.gethostbyname.return_value = "FAKE_IP_ADDRESS"
+ yield remote
+
+
+@pytest.fixture(name="remotews")
+def remotews_fixture():
+ """Patch the samsungtvws SamsungTVWS."""
+ with patch(
+ "homeassistant.components.samsungtv.bridge.SamsungTVWS"
+ ) as remote_class, patch(
+ "homeassistant.components.samsungtv.config_flow.socket"
+ ) as socket1, patch(
+ "homeassistant.components.samsungtv.socket"
+ ) as socket2:
+ remote = mock.Mock()
+ remote.__enter__ = mock.Mock()
+ remote.__exit__ = mock.Mock()
remote_class.return_value = remote
socket1.gethostbyname.return_value = "FAKE_IP_ADDRESS"
socket2.gethostbyname.return_value = "FAKE_IP_ADDRESS"
@@ -140,7 +173,7 @@ async def test_update_off(hass, remote, mock_now):
await setup_samsungtv(hass, MOCK_CONFIG)
with patch(
- "homeassistant.components.samsungtv.media_player.SamsungRemote",
+ "homeassistant.components.samsungtv.bridge.Remote",
side_effect=[OSError("Boom"), mock.DEFAULT],
):
@@ -154,14 +187,13 @@ async def test_update_off(hass, remote, mock_now):
async def test_update_access_denied(hass, remote, mock_now):
- """Testing update tv unhandled response exception."""
+ """Testing update tv access denied exception."""
await setup_samsungtv(hass, MOCK_CONFIG)
with patch(
- "homeassistant.components.samsungtv.media_player.SamsungRemote",
+ "homeassistant.components.samsungtv.bridge.Remote",
side_effect=exceptions.AccessDenied("Boom"),
):
-
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
@@ -174,12 +206,36 @@ async def test_update_access_denied(hass, remote, mock_now):
]
+async def test_update_connection_failure(hass, remotews, mock_now):
+ """Testing update tv connection failure exception."""
+ with patch(
+ "homeassistant.components.samsungtv.bridge.Remote",
+ side_effect=[OSError("Boom"), mock.DEFAULT],
+ ):
+ await setup_samsungtv(hass, MOCK_CONFIGWS)
+
+ with patch(
+ "homeassistant.components.samsungtv.bridge.SamsungTVWS",
+ side_effect=ConnectionFailure("Boom"),
+ ):
+ next_update = mock_now + timedelta(minutes=5)
+ with patch("homeassistant.util.dt.utcnow", return_value=next_update):
+ async_fire_time_changed(hass, next_update)
+ await hass.async_block_till_done()
+
+ assert [
+ flow
+ for flow in hass.config_entries.flow.async_progress()
+ if flow["context"]["source"] == "reauth"
+ ]
+
+
async def test_update_unhandled_response(hass, remote, mock_now):
"""Testing update tv unhandled response exception."""
await setup_samsungtv(hass, MOCK_CONFIG)
with patch(
- "homeassistant.components.samsungtv.media_player.SamsungRemote",
+ "homeassistant.components.samsungtv.bridge.Remote",
side_effect=[exceptions.UnhandledResponse("Boom"), mock.DEFAULT],
):
@@ -334,36 +390,30 @@ async def test_device_class(hass, remote):
assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TV
-async def test_turn_off_websocket(hass, remote):
+async def test_turn_off_websocket(hass, remotews):
"""Test for turn_off."""
- await setup_samsungtv(hass, MOCK_CONFIG)
+ with patch(
+ "homeassistant.components.samsungtv.bridge.Remote",
+ side_effect=[OSError("Boom"), mock.DEFAULT],
+ ):
+ await setup_samsungtv(hass, MOCK_CONFIGWS)
+ assert await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True
+ )
+ # key called
+ assert remotews.send_key.call_count == 1
+ assert remotews.send_key.call_args_list == [call("KEY_POWER")]
+
+
+async def test_turn_off_legacy(hass, remote):
+ """Test for turn_off."""
+ await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON)
assert await hass.services.async_call(
- DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True
+ DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True
)
# key called
assert remote.control.call_count == 1
- assert remote.control.call_args_list == [call("KEY_POWER")]
-
-
-async def test_turn_off_legacy(hass):
- """Test for turn_off."""
- with patch("homeassistant.components.samsungtv.config_flow.socket"), patch(
- "homeassistant.components.samsungtv.config_flow.Remote",
- side_effect=[OSError("Boom"), mock.DEFAULT],
- ), patch(
- "homeassistant.components.samsungtv.media_player.SamsungRemote"
- ) as remote_class, patch(
- "homeassistant.components.samsungtv.socket"
- ):
- remote = mock.Mock()
- remote_class.return_value = remote
- await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON)
- assert await hass.services.async_call(
- DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True
- )
- # key called
- assert remote.control.call_count == 1
- assert remote.control.call_args_list == [call("KEY_POWEROFF")]
+ assert remote.control.call_args_list == [call("KEY_POWEROFF")]
async def test_turn_off_os_error(hass, remote, caplog):
@@ -374,7 +424,7 @@ async def test_turn_off_os_error(hass, remote, caplog):
assert await hass.services.async_call(
DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True
)
- assert "Could not establish connection." in caplog.text
+ assert "Could not establish connection" in caplog.text
async def test_volume_up(hass, remote):
@@ -526,11 +576,12 @@ async def test_play_media(hass, remote):
async def test_play_media_invalid_type(hass, remote):
"""Test for play_media with invalid media type."""
- with patch(
- "homeassistant.components.samsungtv.media_player.SamsungRemote"
- ) as remote, patch("homeassistant.components.samsungtv.config_flow.socket"):
+ with patch("homeassistant.components.samsungtv.bridge.Remote") as remote, patch(
+ "homeassistant.components.samsungtv.config_flow.socket"
+ ):
url = "https://example.com"
await setup_samsungtv(hass, MOCK_CONFIG)
+ remote.reset_mock()
assert await hass.services.async_call(
DOMAIN,
SERVICE_PLAY_MEDIA,
@@ -549,11 +600,12 @@ async def test_play_media_invalid_type(hass, remote):
async def test_play_media_channel_as_string(hass, remote):
"""Test for play_media with invalid channel as string."""
- with patch(
- "homeassistant.components.samsungtv.media_player.SamsungRemote"
- ) as remote, patch("homeassistant.components.samsungtv.config_flow.socket"):
+ with patch("homeassistant.components.samsungtv.bridge.Remote") as remote, patch(
+ "homeassistant.components.samsungtv.config_flow.socket"
+ ):
url = "https://example.com"
await setup_samsungtv(hass, MOCK_CONFIG)
+ remote.reset_mock()
assert await hass.services.async_call(
DOMAIN,
SERVICE_PLAY_MEDIA,
@@ -572,10 +624,11 @@ async def test_play_media_channel_as_string(hass, remote):
async def test_play_media_channel_as_non_positive(hass, remote):
"""Test for play_media with invalid channel as non positive integer."""
- with patch(
- "homeassistant.components.samsungtv.media_player.SamsungRemote"
- ) as remote, patch("homeassistant.components.samsungtv.config_flow.socket"):
+ with patch("homeassistant.components.samsungtv.bridge.Remote") as remote, patch(
+ "homeassistant.components.samsungtv.config_flow.socket"
+ ):
await setup_samsungtv(hass, MOCK_CONFIG)
+ remote.reset_mock()
assert await hass.services.async_call(
DOMAIN,
SERVICE_PLAY_MEDIA,
@@ -610,10 +663,11 @@ async def test_select_source(hass, remote):
async def test_select_source_invalid_source(hass, remote):
"""Test for select_source with invalid source."""
- with patch(
- "homeassistant.components.samsungtv.media_player.SamsungRemote"
- ) as remote, patch("homeassistant.components.samsungtv.config_flow.socket"):
+ with patch("homeassistant.components.samsungtv.bridge.Remote") as remote, patch(
+ "homeassistant.components.samsungtv.config_flow.socket"
+ ):
await setup_samsungtv(hass, MOCK_CONFIG)
+ remote.reset_mock()
assert await hass.services.async_call(
DOMAIN,
SERVICE_SELECT_SOURCE,
diff --git a/tests/components/sense/__init__.py b/tests/components/sense/__init__.py
new file mode 100644
index 00000000000..bf0a87737b9
--- /dev/null
+++ b/tests/components/sense/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Sense integration."""
diff --git a/tests/components/sense/test_config_flow.py b/tests/components/sense/test_config_flow.py
new file mode 100644
index 00000000000..fdce335b7cf
--- /dev/null
+++ b/tests/components/sense/test_config_flow.py
@@ -0,0 +1,75 @@
+"""Test the Sense config flow."""
+from asynctest import patch
+from sense_energy import SenseAPITimeoutException, SenseAuthenticationException
+
+from homeassistant import config_entries, setup
+from homeassistant.components.sense.const import DOMAIN
+
+
+async def test_form(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch("sense_energy.ASyncSenseable.authenticate", return_value=True,), patch(
+ "homeassistant.components.sense.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.sense.async_setup_entry", return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"timeout": "6", "email": "test-email", "password": "test-password"},
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "test-email"
+ assert result2["data"] == {
+ "timeout": 6,
+ "email": "test-email",
+ "password": "test-password",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_invalid_auth(hass):
+ """Test we handle invalid auth."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "sense_energy.ASyncSenseable.authenticate",
+ side_effect=SenseAuthenticationException,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"timeout": "6", "email": "test-email", "password": "test-password"},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "invalid_auth"}
+
+
+async def test_form_cannot_connect(hass):
+ """Test we handle cannot connect error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "sense_energy.ASyncSenseable.authenticate",
+ side_effect=SenseAPITimeoutException,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"timeout": "6", "email": "test-email", "password": "test-password"},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py
index f9d8bb640c3..5d25e666110 100644
--- a/tests/components/sensor/test_device_condition.py
+++ b/tests/components/sensor/test_device_condition.py
@@ -4,7 +4,7 @@ import pytest
import homeassistant.components.automation as automation
from homeassistant.components.sensor import DOMAIN
from homeassistant.components.sensor.device_condition import ENTITY_CONDITIONS
-from homeassistant.const import CONF_PLATFORM, STATE_UNKNOWN
+from homeassistant.const import CONF_PLATFORM, STATE_UNKNOWN, UNIT_PERCENTAGE
from homeassistant.helpers import device_registry
from homeassistant.setup import async_setup_component
@@ -97,13 +97,13 @@ async def test_get_condition_capabilities(hass, device_reg, entity_reg):
expected_capabilities = {
"extra_fields": [
{
- "description": {"suffix": "%"},
+ "description": {"suffix": UNIT_PERCENTAGE},
"name": "above",
"optional": True,
"type": "float",
},
{
- "description": {"suffix": "%"},
+ "description": {"suffix": UNIT_PERCENTAGE},
"name": "below",
"optional": True,
"type": "float",
diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py
index 8e4b5d1792a..54c0c8f7bd0 100644
--- a/tests/components/sensor/test_device_trigger.py
+++ b/tests/components/sensor/test_device_trigger.py
@@ -6,7 +6,7 @@ import pytest
import homeassistant.components.automation as automation
from homeassistant.components.sensor import DOMAIN
from homeassistant.components.sensor.device_trigger import ENTITY_TRIGGERS
-from homeassistant.const import CONF_PLATFORM, STATE_UNKNOWN
+from homeassistant.const import CONF_PLATFORM, STATE_UNKNOWN, UNIT_PERCENTAGE
from homeassistant.helpers import device_registry
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
@@ -102,13 +102,13 @@ async def test_get_trigger_capabilities(hass, device_reg, entity_reg):
expected_capabilities = {
"extra_fields": [
{
- "description": {"suffix": "%"},
+ "description": {"suffix": UNIT_PERCENTAGE},
"name": "above",
"optional": True,
"type": "float",
},
{
- "description": {"suffix": "%"},
+ "description": {"suffix": UNIT_PERCENTAGE},
"name": "below",
"optional": True,
"type": "float",
diff --git a/tests/components/shopping_list/conftest.py b/tests/components/shopping_list/conftest.py
index 44c8000efa2..f63363e2f63 100644
--- a/tests/components/shopping_list/conftest.py
+++ b/tests/components/shopping_list/conftest.py
@@ -1,10 +1,10 @@
"""Shopping list test helpers."""
-from unittest.mock import patch
-
+from asynctest import patch
import pytest
from homeassistant.components.shopping_list import intent as sl_intent
-from homeassistant.setup import async_setup_component
+
+from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
@@ -19,5 +19,10 @@ def mock_shopping_list_io():
@pytest.fixture
async def sl_setup(hass):
"""Set up the shopping list."""
- assert await async_setup_component(hass, "shopping_list", {})
+
+ entry = MockConfigEntry(domain="shopping_list")
+ entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(entry.entry_id)
+
await sl_intent.async_setup_intents(hass)
diff --git a/tests/components/shopping_list/test_config_flow.py b/tests/components/shopping_list/test_config_flow.py
new file mode 100644
index 00000000000..dfc23e18504
--- /dev/null
+++ b/tests/components/shopping_list/test_config_flow.py
@@ -0,0 +1,36 @@
+"""Test config flow."""
+
+from homeassistant import data_entry_flow
+from homeassistant.components.shopping_list.const import DOMAIN
+from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
+
+
+async def test_import(hass):
+ """Test entry will be imported."""
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data={}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+
+async def test_user(hass):
+ """Test we can start a config flow."""
+
+ 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"
+
+
+async def test_user_confirm(hass):
+ """Test we can finish a config flow."""
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data={}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["result"].data == {}
diff --git a/tests/components/shopping_list/test_init.py b/tests/components/shopping_list/test_init.py
index 74c354848a3..bb65dcf631f 100644
--- a/tests/components/shopping_list/test_init.py
+++ b/tests/components/shopping_list/test_init.py
@@ -1,36 +1,33 @@
"""Test shopping list component."""
-import asyncio
from homeassistant.components.websocket_api.const import TYPE_RESULT
from homeassistant.helpers import intent
-@asyncio.coroutine
-def test_add_item(hass, sl_setup):
+async def test_add_item(hass, sl_setup):
"""Test adding an item intent."""
- response = yield from intent.async_handle(
+ response = await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}}
)
assert response.speech["plain"]["speech"] == "I've added beer to your shopping list"
-@asyncio.coroutine
-def test_recent_items_intent(hass, sl_setup):
+async def test_recent_items_intent(hass, sl_setup):
"""Test recent items."""
- yield from intent.async_handle(
+ await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}}
)
- yield from intent.async_handle(
+ await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}}
)
- yield from intent.async_handle(
+ await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "soda"}}
)
- response = yield from intent.async_handle(hass, "test", "HassShoppingListLastItems")
+ response = await intent.async_handle(hass, "test", "HassShoppingListLastItems")
assert (
response.speech["plain"]["speech"]
@@ -38,22 +35,21 @@ def test_recent_items_intent(hass, sl_setup):
)
-@asyncio.coroutine
-def test_deprecated_api_get_all(hass, hass_client, sl_setup):
+async def test_deprecated_api_get_all(hass, hass_client, sl_setup):
"""Test the API."""
- yield from intent.async_handle(
+ await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}}
)
- yield from intent.async_handle(
+ await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}}
)
- client = yield from hass_client()
- resp = yield from client.get("/api/shopping_list")
+ client = await hass_client()
+ resp = await client.get("/api/shopping_list")
assert resp.status == 200
- data = yield from resp.json()
+ data = await resp.json()
assert len(data) == 2
assert data[0]["name"] == "beer"
assert not data[0]["complete"]
@@ -88,35 +84,34 @@ async def test_ws_get_items(hass, hass_ws_client, sl_setup):
assert not data[1]["complete"]
-@asyncio.coroutine
-def test_deprecated_api_update(hass, hass_client, sl_setup):
+async def test_deprecated_api_update(hass, hass_client, sl_setup):
"""Test the API."""
- yield from intent.async_handle(
+ await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}}
)
- yield from intent.async_handle(
+ await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}}
)
beer_id = hass.data["shopping_list"].items[0]["id"]
wine_id = hass.data["shopping_list"].items[1]["id"]
- client = yield from hass_client()
- resp = yield from client.post(
+ client = await hass_client()
+ resp = await client.post(
"/api/shopping_list/item/{}".format(beer_id), json={"name": "soda"}
)
assert resp.status == 200
- data = yield from resp.json()
+ data = await resp.json()
assert data == {"id": beer_id, "name": "soda", "complete": False}
- resp = yield from client.post(
+ resp = await client.post(
"/api/shopping_list/item/{}".format(wine_id), json={"complete": True}
)
assert resp.status == 200
- data = yield from resp.json()
+ data = await resp.json()
assert data == {"id": wine_id, "name": "wine", "complete": True}
beer, wine = hass.data["shopping_list"].items
@@ -166,23 +161,20 @@ async def test_ws_update_item(hass, hass_ws_client, sl_setup):
assert wine == {"id": wine_id, "name": "wine", "complete": True}
-@asyncio.coroutine
-def test_api_update_fails(hass, hass_client, sl_setup):
+async def test_api_update_fails(hass, hass_client, sl_setup):
"""Test the API."""
- yield from intent.async_handle(
+ await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}}
)
- client = yield from hass_client()
- resp = yield from client.post(
- "/api/shopping_list/non_existing", json={"name": "soda"}
- )
+ client = await hass_client()
+ resp = await client.post("/api/shopping_list/non_existing", json={"name": "soda"})
assert resp.status == 404
beer_id = hass.data["shopping_list"].items[0]["id"]
- resp = yield from client.post(
+ resp = await client.post(
"/api/shopping_list/item/{}".format(beer_id), json={"name": 123}
)
@@ -212,29 +204,28 @@ async def test_ws_update_item_fail(hass, hass_ws_client, sl_setup):
assert msg["success"] is False
-@asyncio.coroutine
-def test_deprecated_api_clear_completed(hass, hass_client, sl_setup):
+async def test_deprecated_api_clear_completed(hass, hass_client, sl_setup):
"""Test the API."""
- yield from intent.async_handle(
+ await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}}
)
- yield from intent.async_handle(
+ await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}}
)
beer_id = hass.data["shopping_list"].items[0]["id"]
wine_id = hass.data["shopping_list"].items[1]["id"]
- client = yield from hass_client()
+ client = await hass_client()
# Mark beer as completed
- resp = yield from client.post(
+ resp = await client.post(
"/api/shopping_list/item/{}".format(beer_id), json={"complete": True}
)
assert resp.status == 200
- resp = yield from client.post("/api/shopping_list/clear_completed")
+ resp = await client.post("/api/shopping_list/clear_completed")
assert resp.status == 200
items = hass.data["shopping_list"].items
@@ -272,15 +263,14 @@ async def test_ws_clear_items(hass, hass_ws_client, sl_setup):
assert items[0] == {"id": wine_id, "name": "wine", "complete": False}
-@asyncio.coroutine
-def test_deprecated_api_create(hass, hass_client, sl_setup):
+async def test_deprecated_api_create(hass, hass_client, sl_setup):
"""Test the API."""
- client = yield from hass_client()
- resp = yield from client.post("/api/shopping_list/item", json={"name": "soda"})
+ client = await hass_client()
+ resp = await client.post("/api/shopping_list/item", json={"name": "soda"})
assert resp.status == 200
- data = yield from resp.json()
+ data = await resp.json()
assert data["name"] == "soda"
assert data["complete"] is False
@@ -290,12 +280,11 @@ def test_deprecated_api_create(hass, hass_client, sl_setup):
assert items[0]["complete"] is False
-@asyncio.coroutine
-def test_deprecated_api_create_fail(hass, hass_client, sl_setup):
+async def test_deprecated_api_create_fail(hass, hass_client, sl_setup):
"""Test the API."""
- client = yield from hass_client()
- resp = yield from client.post("/api/shopping_list/item", json={"name": 1234})
+ client = await hass_client()
+ resp = await client.post("/api/shopping_list/item", json={"name": 1234})
assert resp.status == 400
assert len(hass.data["shopping_list"].items) == 0
diff --git a/tests/components/sighthound/test_image_processing.py b/tests/components/sighthound/test_image_processing.py
index 4548a3a6a35..1d73ace184e 100644
--- a/tests/components/sighthound/test_image_processing.py
+++ b/tests/components/sighthound/test_image_processing.py
@@ -1,6 +1,11 @@
"""Tests for the Sighthound integration."""
-from unittest.mock import patch
+from copy import deepcopy
+import datetime
+import os
+from pathlib import Path
+from unittest import mock
+from PIL import UnidentifiedImageError
import pytest
import simplehound.core as hound
@@ -10,6 +15,8 @@ from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY
from homeassistant.core import callback
from homeassistant.setup import async_setup_component
+TEST_DIR = os.path.dirname(__file__)
+
VALID_CONFIG = {
ip.DOMAIN: {
"platform": "sighthound",
@@ -36,11 +43,13 @@ MOCK_DETECTIONS = {
"requestId": "545cec700eac4d389743e2266264e84b",
}
+MOCK_NOW = datetime.datetime(2020, 2, 20, 10, 5, 3)
+
@pytest.fixture
def mock_detections():
"""Return a mock detection."""
- with patch(
+ with mock.patch(
"simplehound.core.cloud.detect", return_value=MOCK_DETECTIONS
) as detection:
yield detection
@@ -49,16 +58,35 @@ def mock_detections():
@pytest.fixture
def mock_image():
"""Return a mock camera image."""
- with patch(
+ with mock.patch(
"homeassistant.components.demo.camera.DemoCamera.camera_image",
return_value=b"Test",
) as image:
yield image
+@pytest.fixture
+def mock_bad_image_data():
+ """Mock bad image data."""
+ with mock.patch(
+ "homeassistant.components.sighthound.image_processing.Image.open",
+ side_effect=UnidentifiedImageError,
+ ) as bad_data:
+ yield bad_data
+
+
+@pytest.fixture
+def mock_now():
+ """Return a mock now datetime."""
+ with mock.patch("homeassistant.util.dt.now", return_value=MOCK_NOW) as now_dt:
+ yield now_dt
+
+
async def test_bad_api_key(hass, caplog):
"""Catch bad api key."""
- with patch("simplehound.core.cloud.detect", side_effect=hound.SimplehoundException):
+ with mock.patch(
+ "simplehound.core.cloud.detect", side_effect=hound.SimplehoundException
+ ):
await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG)
assert "Sighthound error" in caplog.text
assert not hass.states.get(VALID_ENTITY_ID)
@@ -91,3 +119,69 @@ async def test_process_image(hass, mock_image, mock_detections):
state = hass.states.get(VALID_ENTITY_ID)
assert state.state == "2"
assert len(person_events) == 2
+
+
+async def test_catch_bad_image(
+ hass, caplog, mock_image, mock_detections, mock_bad_image_data
+):
+ """Process an image."""
+ valid_config_save_file = deepcopy(VALID_CONFIG)
+ valid_config_save_file[ip.DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR})
+ await async_setup_component(hass, ip.DOMAIN, valid_config_save_file)
+ assert hass.states.get(VALID_ENTITY_ID)
+
+ data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
+ await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data)
+ await hass.async_block_till_done()
+ assert "Sighthound unable to process image" in caplog.text
+
+
+async def test_save_image(hass, mock_image, mock_detections):
+ """Save a processed image."""
+ valid_config_save_file = deepcopy(VALID_CONFIG)
+ valid_config_save_file[ip.DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR})
+ await async_setup_component(hass, ip.DOMAIN, valid_config_save_file)
+ assert hass.states.get(VALID_ENTITY_ID)
+
+ with mock.patch(
+ "homeassistant.components.sighthound.image_processing.Image.open"
+ ) as pil_img_open:
+ pil_img = pil_img_open.return_value
+ pil_img = pil_img.convert.return_value
+ data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
+ await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data)
+ await hass.async_block_till_done()
+ state = hass.states.get(VALID_ENTITY_ID)
+ assert state.state == "2"
+ assert pil_img.save.call_count == 1
+
+ directory = Path(TEST_DIR)
+ latest_save_path = directory / "sighthound_demo_camera_latest.jpg"
+ assert pil_img.save.call_args_list[0] == mock.call(latest_save_path)
+
+
+async def test_save_timestamped_image(hass, mock_image, mock_detections, mock_now):
+ """Save a processed image."""
+ valid_config_save_ts_file = deepcopy(VALID_CONFIG)
+ valid_config_save_ts_file[ip.DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR})
+ valid_config_save_ts_file[ip.DOMAIN].update({sh.CONF_SAVE_TIMESTAMPTED_FILE: True})
+ await async_setup_component(hass, ip.DOMAIN, valid_config_save_ts_file)
+ assert hass.states.get(VALID_ENTITY_ID)
+
+ with mock.patch(
+ "homeassistant.components.sighthound.image_processing.Image.open"
+ ) as pil_img_open:
+ pil_img = pil_img_open.return_value
+ pil_img = pil_img.convert.return_value
+ data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
+ await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data)
+ await hass.async_block_till_done()
+ state = hass.states.get(VALID_ENTITY_ID)
+ assert state.state == "2"
+ assert pil_img.save.call_count == 2
+
+ directory = Path(TEST_DIR)
+ timestamp_save_path = (
+ directory / "sighthound_demo_camera_2020-02-20_10:05:03.jpg"
+ )
+ assert pil_img.save.call_args_list[1] == mock.call(timestamp_save_path)
diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py
index 2d40495215a..496c6d88954 100644
--- a/tests/components/simplisafe/test_config_flow.py
+++ b/tests/components/simplisafe/test_config_flow.py
@@ -2,8 +2,11 @@
import json
from unittest.mock import MagicMock, PropertyMock, mock_open, patch
+from simplipy.errors import SimplipyError
+
from homeassistant import data_entry_flow
from homeassistant.components.simplisafe import DOMAIN, config_flow
+from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from tests.common import MockConfigEntry, mock_coro
@@ -20,22 +23,25 @@ async def test_duplicate_error(hass):
"""Test that errors are shown when duplicates are added."""
conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}
- MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
- flow = config_flow.SimpliSafeFlowHandler()
- flow.hass = hass
+ MockConfigEntry(domain=DOMAIN, unique_id="user@email.com", data=conf).add_to_hass(
+ hass
+ )
- result = await flow.async_step_user(user_input=conf)
- assert result["errors"] == {CONF_USERNAME: "identifier_exists"}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=conf
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
async def test_invalid_credentials(hass):
"""Test that invalid credentials throws an error."""
- from simplipy.errors import SimplipyError
-
conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}
flow = config_flow.SimpliSafeFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
with patch(
"simplipy.API.login_via_credentials",
@@ -49,6 +55,7 @@ async def test_show_form(hass):
"""Test that the form is served with no input."""
flow = config_flow.SimpliSafeFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
result = await flow.async_step_user(user_input=None)
@@ -62,6 +69,7 @@ async def test_step_import(hass):
flow = config_flow.SimpliSafeFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
mop = mock_open(read_data=json.dumps({"refresh_token": "12345"}))
@@ -91,6 +99,7 @@ async def test_step_user(hass):
flow = config_flow.SimpliSafeFlowHandler()
flow.hass = hass
+ flow.context = {"source": SOURCE_USER}
mop = mock_open(read_data=json.dumps({"refresh_token": "12345"}))
diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py
index 0c9d889d558..ae80316676a 100644
--- a/tests/components/smartthings/test_init.py
+++ b/tests/components/smartthings/test_init.py
@@ -423,7 +423,10 @@ async def test_event_handler_dispatches_updated_devices(
data={"codeId": "1"},
)
request = event_request_factory(device_ids=device_ids, events=[event])
- config_entry.data[CONF_INSTALLED_APP_ID] = request.installed_app_id
+ config_entry.data = {
+ **config_entry.data,
+ CONF_INSTALLED_APP_ID: request.installed_app_id,
+ }
called = False
def signal(ids):
@@ -479,7 +482,10 @@ async def test_event_handler_fires_button_events(
device.device_id, capability="button", attribute="button", value="pushed"
)
request = event_request_factory(events=[event])
- config_entry.data[CONF_INSTALLED_APP_ID] = request.installed_app_id
+ config_entry.data = {
+ **config_entry.data,
+ CONF_INSTALLED_APP_ID: request.installed_app_id,
+ }
called = False
def handler(evt):
diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py
index f285bc65d8d..fd8bfdca44c 100644
--- a/tests/components/smartthings/test_sensor.py
+++ b/tests/components/smartthings/test_sensor.py
@@ -13,6 +13,7 @@ from homeassistant.const import (
ATTR_FRIENDLY_NAME,
ATTR_UNIT_OF_MEASUREMENT,
STATE_UNKNOWN,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -37,7 +38,7 @@ async def test_entity_state(hass, device_factory):
await setup_platform(hass, SENSOR_DOMAIN, devices=[device])
state = hass.states.get("sensor.sensor_1_battery")
assert state.state == "100"
- assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "%"
+ assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UNIT_PERCENTAGE
assert state.attributes[ATTR_FRIENDLY_NAME] == device.label + " Battery"
diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py
index 300d201079b..1629a3d29c2 100644
--- a/tests/components/sonarr/test_sensor.py
+++ b/tests/components/sonarr/test_sensor.py
@@ -6,7 +6,7 @@ import unittest
import pytest
import homeassistant.components.sonarr.sensor as sonarr
-from homeassistant.const import DATA_GIGABYTES
+from homeassistant.const import DATA_GIGABYTES, UNIT_PERCENTAGE
from tests.common import get_test_home_assistant
@@ -569,7 +569,10 @@ class TestSonarrSetup(unittest.TestCase):
assert "mdi:download" == device.icon
assert "Episodes" == device.unit_of_measurement
assert "Sonarr Queue" == device.name
- assert "100.00%" == device.device_state_attributes["Game of Thrones S03E08"]
+ assert (
+ f"100.00{UNIT_PERCENTAGE}"
+ == device.device_state_attributes["Game of Thrones S03E08"]
+ )
@unittest.mock.patch("requests.get", side_effect=mocked_requests_get)
def test_series(self, req_mock):
diff --git a/tests/components/soundtouch/test_media_player.py b/tests/components/soundtouch/test_media_player.py
index b18f9efda97..e69cec12ba3 100644
--- a/tests/components/soundtouch/test_media_player.py
+++ b/tests/components/soundtouch/test_media_player.py
@@ -19,7 +19,11 @@ from homeassistant.components.media_player.const import (
)
from homeassistant.components.soundtouch import media_player as soundtouch
from homeassistant.components.soundtouch.const import DOMAIN
-from homeassistant.components.soundtouch.media_player import DATA_SOUNDTOUCH
+from homeassistant.components.soundtouch.media_player import (
+ ATTR_SOUNDTOUCH_GROUP,
+ ATTR_SOUNDTOUCH_ZONE,
+ DATA_SOUNDTOUCH,
+)
from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.setup import async_setup_component
@@ -154,9 +158,9 @@ def _mocked_presets(*args, **kwargs):
class MockPreset(Preset):
"""Mock preset."""
- def __init__(self, id):
+ def __init__(self, id_):
"""Init the class."""
- self._id = id
+ self._id = id_
self._name = "preset"
@@ -318,8 +322,8 @@ async def test_playing_media(mocked_status, mocked_volume, hass, one_device):
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
entity_1_state = hass.states.get("media_player.soundtouch_1")
assert entity_1_state.state == STATE_PLAYING
@@ -336,8 +340,8 @@ async def test_playing_unknown_media(mocked_status, mocked_volume, hass, one_dev
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
entity_1_state = hass.states.get("media_player.soundtouch_1")
assert entity_1_state.state == STATE_PLAYING
@@ -349,8 +353,8 @@ async def test_playing_radio(mocked_status, mocked_volume, hass, one_device):
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
entity_1_state = hass.states.get("media_player.soundtouch_1")
assert entity_1_state.state == STATE_PLAYING
@@ -363,8 +367,8 @@ async def test_get_volume_level(mocked_status, mocked_volume, hass, one_device):
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
entity_1_state = hass.states.get("media_player.soundtouch_1")
assert entity_1_state.attributes["volume_level"] == 0.12
@@ -376,8 +380,8 @@ async def test_get_state_off(mocked_status, mocked_volume, hass, one_device):
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
entity_1_state = hass.states.get("media_player.soundtouch_1")
assert entity_1_state.state == STATE_OFF
@@ -389,8 +393,8 @@ async def test_get_state_pause(mocked_status, mocked_volume, hass, one_device):
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
entity_1_state = hass.states.get("media_player.soundtouch_1")
assert entity_1_state.state == STATE_PAUSED
@@ -402,8 +406,8 @@ async def test_is_muted(mocked_status, mocked_volume, hass, one_device):
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
entity_1_state = hass.states.get("media_player.soundtouch_1")
assert entity_1_state.attributes["is_volume_muted"]
@@ -414,8 +418,8 @@ async def test_media_commands(mocked_status, mocked_volume, hass, one_device):
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
entity_1_state = hass.states.get("media_player.soundtouch_1")
assert entity_1_state.attributes["supported_features"] == 18365
@@ -429,13 +433,13 @@ async def test_should_turn_off(
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
await hass.services.async_call(
"media_player", "turn_off", {"entity_id": "media_player.soundtouch_1"}, True,
)
- assert mocked_status.call_count == 2
+ assert mocked_status.call_count == 3
assert mocked_power_off.call_count == 1
@@ -448,13 +452,13 @@ async def test_should_turn_on(
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
await hass.services.async_call(
"media_player", "turn_on", {"entity_id": "media_player.soundtouch_1"}, True,
)
- assert mocked_status.call_count == 2
+ assert mocked_status.call_count == 3
assert mocked_power_on.call_count == 1
@@ -466,13 +470,13 @@ async def test_volume_up(
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
await hass.services.async_call(
"media_player", "volume_up", {"entity_id": "media_player.soundtouch_1"}, True,
)
- assert mocked_volume.call_count == 2
+ assert mocked_volume.call_count == 3
assert mocked_volume_up.call_count == 1
@@ -484,13 +488,13 @@ async def test_volume_down(
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
await hass.services.async_call(
"media_player", "volume_down", {"entity_id": "media_player.soundtouch_1"}, True,
)
- assert mocked_volume.call_count == 2
+ assert mocked_volume.call_count == 3
assert mocked_volume_down.call_count == 1
@@ -502,8 +506,8 @@ async def test_set_volume_level(
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
await hass.services.async_call(
"media_player",
@@ -511,7 +515,7 @@ async def test_set_volume_level(
{"entity_id": "media_player.soundtouch_1", "volume_level": 0.17},
True,
)
- assert mocked_volume.call_count == 2
+ assert mocked_volume.call_count == 3
mocked_set_volume.assert_called_with(17)
@@ -521,8 +525,8 @@ async def test_mute(mocked_mute, mocked_status, mocked_volume, hass, one_device)
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
await hass.services.async_call(
"media_player",
@@ -530,7 +534,7 @@ async def test_mute(mocked_mute, mocked_status, mocked_volume, hass, one_device)
{"entity_id": "media_player.soundtouch_1", "is_volume_muted": True},
True,
)
- assert mocked_volume.call_count == 2
+ assert mocked_volume.call_count == 3
assert mocked_mute.call_count == 1
@@ -540,13 +544,13 @@ async def test_play(mocked_play, mocked_status, mocked_volume, hass, one_device)
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
await hass.services.async_call(
"media_player", "media_play", {"entity_id": "media_player.soundtouch_1"}, True,
)
- assert mocked_status.call_count == 2
+ assert mocked_status.call_count == 3
assert mocked_play.call_count == 1
@@ -556,13 +560,13 @@ async def test_pause(mocked_pause, mocked_status, mocked_volume, hass, one_devic
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
await hass.services.async_call(
"media_player", "media_pause", {"entity_id": "media_player.soundtouch_1"}, True,
)
- assert mocked_status.call_count == 2
+ assert mocked_status.call_count == 3
assert mocked_pause.call_count == 1
@@ -574,8 +578,8 @@ async def test_play_pause(
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
await hass.services.async_call(
"media_player",
@@ -583,7 +587,7 @@ async def test_play_pause(
{"entity_id": "media_player.soundtouch_1"},
True,
)
- assert mocked_status.call_count == 2
+ assert mocked_status.call_count == 3
assert mocked_play_pause.call_count == 1
@@ -601,8 +605,8 @@ async def test_next_previous_track(
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
await hass.services.async_call(
"media_player",
@@ -610,7 +614,7 @@ async def test_next_previous_track(
{"entity_id": "media_player.soundtouch_1"},
True,
)
- assert mocked_status.call_count == 2
+ assert mocked_status.call_count == 3
assert mocked_next_track.call_count == 1
await hass.services.async_call(
@@ -619,7 +623,7 @@ async def test_next_previous_track(
{"entity_id": "media_player.soundtouch_1"},
True,
)
- assert mocked_status.call_count == 3
+ assert mocked_status.call_count == 4
assert mocked_previous_track.call_count == 1
@@ -632,8 +636,8 @@ async def test_play_media(
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
await hass.services.async_call(
"media_player",
@@ -670,8 +674,8 @@ async def test_play_media_url(
await setup_soundtouch(hass, DEVICE_1_CONFIG)
assert one_device.call_count == 1
- assert mocked_status.call_count == 1
- assert mocked_volume.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
await hass.services.async_call(
"media_player",
@@ -695,8 +699,8 @@ async def test_play_everywhere(
await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG])
assert mocked_device.call_count == 2
- assert mocked_status.call_count == 2
- assert mocked_volume.call_count == 2
+ assert mocked_status.call_count == 4
+ assert mocked_volume.call_count == 4
# one master, one slave => create zone
await hass.services.async_call(
@@ -740,8 +744,8 @@ async def test_create_zone(
await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG])
assert mocked_device.call_count == 2
- assert mocked_status.call_count == 2
- assert mocked_volume.call_count == 2
+ assert mocked_status.call_count == 4
+ assert mocked_volume.call_count == 4
# one master, one slave => create zone
await hass.services.async_call(
@@ -783,8 +787,8 @@ async def test_remove_zone_slave(
await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG])
assert mocked_device.call_count == 2
- assert mocked_status.call_count == 2
- assert mocked_volume.call_count == 2
+ assert mocked_status.call_count == 4
+ assert mocked_volume.call_count == 4
# remove one slave
await hass.services.async_call(
@@ -826,8 +830,8 @@ async def test_add_zone_slave(
await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG])
assert mocked_device.call_count == 2
- assert mocked_status.call_count == 2
- assert mocked_volume.call_count == 2
+ assert mocked_status.call_count == 4
+ assert mocked_volume.call_count == 4
# add one slave
await hass.services.async_call(
@@ -858,3 +862,43 @@ async def test_add_zone_slave(
True,
)
assert mocked_add_zone_slave.call_count == 1
+
+
+@patch("libsoundtouch.device.SoundTouchDevice.create_zone")
+async def test_zone_attributes(
+ mocked_create_zone, mocked_status, mocked_volume, hass, two_zones,
+):
+ """Test play everywhere."""
+ mocked_device = two_zones
+ await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG])
+
+ assert mocked_device.call_count == 2
+ assert mocked_status.call_count == 4
+ assert mocked_volume.call_count == 4
+
+ entity_1_state = hass.states.get("media_player.soundtouch_1")
+ assert entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["is_master"]
+ assert (
+ entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["master"]
+ == "media_player.soundtouch_1"
+ )
+ assert entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["slaves"] == [
+ "media_player.soundtouch_2"
+ ]
+ assert entity_1_state.attributes[ATTR_SOUNDTOUCH_GROUP] == [
+ "media_player.soundtouch_1",
+ "media_player.soundtouch_2",
+ ]
+ entity_2_state = hass.states.get("media_player.soundtouch_2")
+ assert not entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["is_master"]
+ assert (
+ entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["master"]
+ == "media_player.soundtouch_1"
+ )
+ assert entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["slaves"] == [
+ "media_player.soundtouch_2"
+ ]
+ assert entity_2_state.attributes[ATTR_SOUNDTOUCH_GROUP] == [
+ "media_player.soundtouch_1",
+ "media_player.soundtouch_2",
+ ]
diff --git a/tests/components/spaceapi/test_init.py b/tests/components/spaceapi/test_init.py
index 840931d073b..78c7991d9b3 100644
--- a/tests/components/spaceapi/test_init.py
+++ b/tests/components/spaceapi/test_init.py
@@ -5,6 +5,7 @@ from unittest.mock import patch
import pytest
from homeassistant.components.spaceapi import DOMAIN, SPACEAPI_VERSION, URL_API_SPACEAPI
+from homeassistant.const import UNIT_PERCENTAGE
from homeassistant.setup import async_setup_component
from tests.common import mock_coro
@@ -62,7 +63,9 @@ SENSOR_OUTPUT = {
{"location": "Home", "name": "temp1", "unit": "°C", "value": "25"},
{"location": "Home", "name": "temp2", "unit": "°C", "value": "23"},
],
- "humidity": [{"location": "Home", "name": "hum1", "unit": "%", "value": "88"}],
+ "humidity": [
+ {"location": "Home", "name": "hum1", "unit": UNIT_PERCENTAGE, "value": "88"}
+ ],
}
@@ -74,7 +77,9 @@ def mock_client(hass, hass_client):
hass.states.async_set("test.temp1", 25, attributes={"unit_of_measurement": "°C"})
hass.states.async_set("test.temp2", 23, attributes={"unit_of_measurement": "°C"})
- hass.states.async_set("test.hum1", 88, attributes={"unit_of_measurement": "%"})
+ hass.states.async_set(
+ "test.hum1", 88, attributes={"unit_of_measurement": UNIT_PERCENTAGE}
+ )
return hass.loop.run_until_complete(hass_client())
diff --git a/tests/components/startca/test_sensor.py b/tests/components/startca/test_sensor.py
index 82748c122ab..23cc892900b 100644
--- a/tests/components/startca/test_sensor.py
+++ b/tests/components/startca/test_sensor.py
@@ -1,7 +1,7 @@
"""Tests for the Start.ca sensor platform."""
from homeassistant.bootstrap import async_setup_component
from homeassistant.components.startca.sensor import StartcaData
-from homeassistant.const import DATA_GIGABYTES
+from homeassistant.const import DATA_GIGABYTES, UNIT_PERCENTAGE
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -52,7 +52,7 @@ async def test_capped_setup(hass, aioclient_mock):
await async_setup_component(hass, "sensor", {"sensor": config})
state = hass.states.get("sensor.start_ca_usage_ratio")
- assert state.attributes.get("unit_of_measurement") == "%"
+ assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
assert state.state == "76.24"
state = hass.states.get("sensor.start_ca_usage")
@@ -147,7 +147,7 @@ async def test_unlimited_setup(hass, aioclient_mock):
await async_setup_component(hass, "sensor", {"sensor": config})
state = hass.states.get("sensor.start_ca_usage_ratio")
- assert state.attributes.get("unit_of_measurement") == "%"
+ assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
assert state.state == "0"
state = hass.states.get("sensor.start_ca_usage")
diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py
index cec669da134..df79d0750b4 100644
--- a/tests/components/statistics/test_sensor.py
+++ b/tests/components/statistics/test_sensor.py
@@ -36,7 +36,7 @@ class TestStatisticsSensor(unittest.TestCase):
self.variance = round(statistics.variance(self.values), 2)
self.change = round(self.values[-1] - self.values[0], 2)
self.average_change = round(self.change / (len(self.values) - 1), 2)
- self.change_rate = round(self.average_change / (60 * (self.count - 1)), 2)
+ self.change_rate = round(self.change / (60 * (self.count - 1)), 2)
def teardown_method(self, method):
"""Stop everything that was started."""
diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py
index 0ad87b59a81..92f0ed9fd16 100644
--- a/tests/components/system_log/test_init.py
+++ b/tests/components/system_log/test_init.py
@@ -30,6 +30,9 @@ def _generate_and_log_exception(exception, log):
def assert_log(log, exception, message, level):
"""Assert that specified values are in a specific log entry."""
+ if not isinstance(message, list):
+ message = [message]
+
assert log["name"] == "test_logger"
assert exception in log["exception"]
assert message == log["message"]
@@ -39,7 +42,7 @@ def assert_log(log, exception, message, level):
def get_frame(name):
"""Get log stack frame."""
- return (name, None, None, None)
+ return (name, 5, None, None)
async def test_normal_logs(hass, hass_client):
@@ -134,22 +137,45 @@ async def test_remove_older_logs(hass, hass_client):
assert_log(log[1], "", "error message 2", "ERROR")
+def log_msg(nr=2):
+ """Log an error at same line."""
+ _LOGGER.error(f"error message %s", nr)
+
+
async def test_dedup_logs(hass, hass_client):
"""Test that duplicate log entries are dedup."""
- await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ await async_setup_component(hass, system_log.DOMAIN, {})
_LOGGER.error("error message 1")
- _LOGGER.error("error message 2")
- _LOGGER.error("error message 2")
+ log_msg()
+ log_msg("2-2")
_LOGGER.error("error message 3")
- log = await get_error_log(hass, hass_client, 2)
+ log = await get_error_log(hass, hass_client, 3)
assert_log(log[0], "", "error message 3", "ERROR")
assert log[1]["count"] == 2
- assert_log(log[1], "", "error message 2", "ERROR")
+ assert_log(log[1], "", ["error message 2", "error message 2-2"], "ERROR")
- _LOGGER.error("error message 2")
- log = await get_error_log(hass, hass_client, 2)
- assert_log(log[0], "", "error message 2", "ERROR")
- assert log[0]["timestamp"] > log[0]["first_occured"]
+ log_msg()
+ log = await get_error_log(hass, hass_client, 3)
+ assert_log(log[0], "", ["error message 2", "error message 2-2"], "ERROR")
+ assert log[0]["timestamp"] > log[0]["first_occurred"]
+
+ log_msg("2-3")
+ log_msg("2-4")
+ log_msg("2-5")
+ log_msg("2-6")
+ log = await get_error_log(hass, hass_client, 3)
+ assert_log(
+ log[0],
+ "",
+ [
+ "error message 2-2",
+ "error message 2-3",
+ "error message 2-4",
+ "error message 2-5",
+ "error message 2-6",
+ ],
+ "ERROR",
+ )
async def test_clear_logs(hass, hass_client):
@@ -218,7 +244,7 @@ async def test_unknown_path(hass, hass_client):
_LOGGER.findCaller = MagicMock(return_value=("unknown_path", 0, None, None))
_LOGGER.error("error message")
log = (await get_error_log(hass, hass_client, 1))[0]
- assert log["source"] == "unknown_path"
+ assert log["source"] == ["unknown_path", 0]
def log_error_from_test_path(path):
@@ -250,7 +276,7 @@ async def test_homeassistant_path(hass, hass_client):
):
log_error_from_test_path("venv_path/homeassistant/component/component.py")
log = (await get_error_log(hass, hass_client, 1))[0]
- assert log["source"] == "component/component.py"
+ assert log["source"] == ["component/component.py", 5]
async def test_config_path(hass, hass_client):
@@ -259,13 +285,4 @@ async def test_config_path(hass, hass_client):
with patch.object(hass.config, "config_dir", new="config"):
log_error_from_test_path("config/custom_component/test.py")
log = (await get_error_log(hass, hass_client, 1))[0]
- assert log["source"] == "custom_component/test.py"
-
-
-async def test_netdisco_path(hass, hass_client):
- """Test error logged from netdisco path."""
- await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
- with patch.dict("sys.modules", netdisco=MagicMock(__path__=["venv_path/netdisco"])):
- log_error_from_test_path("venv_path/netdisco/disco_component.py")
- log = (await get_error_log(hass, hass_client, 1))[0]
- assert log["source"] == "disco_component.py"
+ assert log["source"] == ["custom_component/test.py", 5]
diff --git a/tests/components/teksavvy/test_sensor.py b/tests/components/teksavvy/test_sensor.py
index 641112e6362..e3e7eae36a8 100644
--- a/tests/components/teksavvy/test_sensor.py
+++ b/tests/components/teksavvy/test_sensor.py
@@ -1,7 +1,7 @@
"""Tests for the TekSavvy sensor platform."""
from homeassistant.bootstrap import async_setup_component
from homeassistant.components.teksavvy.sensor import TekSavvyData
-from homeassistant.const import DATA_GIGABYTES
+from homeassistant.const import DATA_GIGABYTES, UNIT_PERCENTAGE
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -74,7 +74,7 @@ async def test_capped_setup(hass, aioclient_mock):
assert state.state == "235.57"
state = hass.states.get("sensor.teksavvy_usage_ratio")
- assert state.attributes.get("unit_of_measurement") == "%"
+ assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
assert state.state == "56.69"
state = hass.states.get("sensor.teksavvy_usage")
@@ -159,7 +159,7 @@ async def test_unlimited_setup(hass, aioclient_mock):
assert state.state == "226.75"
state = hass.states.get("sensor.teksavvy_usage_ratio")
- assert state.attributes.get("unit_of_measurement") == "%"
+ assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
assert state.state == "0"
state = hass.states.get("sensor.teksavvy_remaining")
diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py
index 9980691085b..5109607d799 100644
--- a/tests/components/template/test_cover.py
+++ b/tests/components/template/test_cover.py
@@ -270,26 +270,22 @@ async def test_template_mutex(hass, calls):
assert hass.states.async_all() == []
-async def test_template_open_or_position(hass, calls):
+async def test_template_open_or_position(hass, caplog):
"""Test that at least one of open_cover or set_position is used."""
- with assert_setup_component(1, "cover"):
- assert await setup.async_setup_component(
- hass,
- "cover",
- {
- "cover": {
- "platform": "template",
- "covers": {
- "test_template_cover": {"value_template": "{{ 1 == 1 }}"}
- },
- }
- },
- )
-
- await hass.async_start()
+ assert await setup.async_setup_component(
+ hass,
+ "cover",
+ {
+ "cover": {
+ "platform": "template",
+ "covers": {"test_template_cover": {"value_template": "{{ 1 == 1 }}"}},
+ }
+ },
+ )
await hass.async_block_till_done()
assert hass.states.async_all() == []
+ assert "Invalid config for [cover.template]" in caplog.text
async def test_template_open_and_close(hass, calls):
diff --git a/tests/components/transmission/test_config_flow.py b/tests/components/transmission/test_config_flow.py
index 80e6bd55017..bb790b025ea 100644
--- a/tests/components/transmission/test_config_flow.py
+++ b/tests/components/transmission/test_config_flow.py
@@ -6,12 +6,12 @@ import pytest
from transmissionrpc.error import TransmissionError
from homeassistant import data_entry_flow
+from homeassistant.components import transmission
from homeassistant.components.transmission import config_flow
from homeassistant.components.transmission.const import (
DEFAULT_NAME,
DEFAULT_PORT,
DEFAULT_SCAN_INTERVAL,
- DOMAIN,
)
from homeassistant.const import (
CONF_HOST,
@@ -73,6 +73,15 @@ def mock_api_unknown_error():
yield
+@pytest.fixture(name="transmission_setup", autouse=True)
+def transmission_setup_fixture():
+ """Mock transmission entry setup."""
+ with patch(
+ "homeassistant.components.transmission.async_setup_entry", return_value=True
+ ):
+ yield
+
+
def init_config_flow(hass):
"""Init a configuration flow."""
flow = config_flow.TransmissionFlowHandler()
@@ -80,17 +89,21 @@ def init_config_flow(hass):
return flow
-async def test_flow_works(hass, api):
+async def test_flow_user_config(hass, api):
"""Test user config."""
- flow = init_config_flow(hass)
-
- result = await flow.async_step_user()
+ result = await hass.config_entries.flow.async_init(
+ transmission.DOMAIN, context={"source": "user"}
+ )
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
- # test with required fields only
- result = await flow.async_step_user(
- {CONF_NAME: NAME, CONF_HOST: HOST, CONF_PORT: PORT}
+
+async def test_flow_required_fields(hass, api):
+ """Test with required fields only."""
+ result = await hass.config_entries.flow.async_init(
+ transmission.DOMAIN,
+ context={"source": "user"},
+ data={CONF_NAME: NAME, CONF_HOST: HOST, CONF_PORT: PORT},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -99,8 +112,12 @@ async def test_flow_works(hass, api):
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_PORT] == PORT
- # test with all provided
- result = await flow.async_step_user(MOCK_ENTRY)
+
+async def test_flow_all_provided(hass, api):
+ """Test with all provided."""
+ result = await hass.config_entries.flow.async_init(
+ transmission.DOMAIN, context={"source": "user"}, data=MOCK_ENTRY
+ )
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == NAME
@@ -114,7 +131,7 @@ async def test_flow_works(hass, api):
async def test_options(hass):
"""Test updating options."""
entry = MockConfigEntry(
- domain=DOMAIN,
+ domain=transmission.DOMAIN,
title=CONF_NAME,
data=MOCK_ENTRY,
options={CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL},
@@ -174,13 +191,14 @@ async def test_import(hass, api):
async def test_host_already_configured(hass, api):
"""Test host is already configured."""
entry = MockConfigEntry(
- domain=DOMAIN,
+ domain=transmission.DOMAIN,
data=MOCK_ENTRY,
options={CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL},
)
entry.add_to_hass(hass)
- flow = init_config_flow(hass)
- result = await flow.async_step_user(MOCK_ENTRY)
+ result = await hass.config_entries.flow.async_init(
+ transmission.DOMAIN, context={"source": "user"}, data=MOCK_ENTRY
+ )
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
@@ -189,7 +207,7 @@ async def test_host_already_configured(hass, api):
async def test_name_already_configured(hass, api):
"""Test name is already configured."""
entry = MockConfigEntry(
- domain=DOMAIN,
+ domain=transmission.DOMAIN,
data=MOCK_ENTRY,
options={CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL},
)
@@ -197,8 +215,9 @@ async def test_name_already_configured(hass, api):
mock_entry = MOCK_ENTRY.copy()
mock_entry[CONF_HOST] = "0.0.0.0"
- flow = init_config_flow(hass)
- result = await flow.async_step_user(mock_entry)
+ result = await hass.config_entries.flow.async_init(
+ transmission.DOMAIN, context={"source": "user"}, data=mock_entry
+ )
assert result["type"] == "form"
assert result["errors"] == {CONF_NAME: "name_exists"}
diff --git a/tests/components/uk_transport/test_sensor.py b/tests/components/uk_transport/test_sensor.py
index ce568a64b95..4979efc22dc 100644
--- a/tests/components/uk_transport/test_sensor.py
+++ b/tests/components/uk_transport/test_sensor.py
@@ -2,6 +2,7 @@
import re
import unittest
+from asynctest import patch
import requests_mock
from homeassistant.components.uk_transport.sensor import (
@@ -17,6 +18,7 @@ from homeassistant.components.uk_transport.sensor import (
UkTransportSensor,
)
from homeassistant.setup import setup_component
+from homeassistant.util.dt import now
from tests.common import get_test_home_assistant, load_fixture
@@ -77,7 +79,9 @@ class TestUkTransportSensor(unittest.TestCase):
@requests_mock.Mocker()
def test_train(self, mock_req):
"""Test for operational uk_transport sensor with proper attributes."""
- with requests_mock.Mocker() as mock_req:
+ with requests_mock.Mocker() as mock_req, patch(
+ "homeassistant.util.dt.now", return_value=now().replace(hour=13)
+ ):
uri = re.compile(UkTransportSensor.TRANSPORT_API_URL_BASE + "*")
mock_req.get(uri, text=load_fixture("uk_transport_train.json"))
assert setup_component(self.hass, "sensor", {"sensor": self.config})
diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py
index 64d1ab9775e..9a280ffe9e6 100644
--- a/tests/components/unifi/test_config_flow.py
+++ b/tests/components/unifi/test_config_flow.py
@@ -5,7 +5,18 @@ from asynctest import patch
from homeassistant import data_entry_flow
from homeassistant.components import unifi
from homeassistant.components.unifi import config_flow
-from homeassistant.components.unifi.const import CONF_CONTROLLER, CONF_SITE_ID
+from homeassistant.components.unifi.config_flow import CONF_NEW_CLIENT
+from homeassistant.components.unifi.const import (
+ CONF_ALLOW_BANDWIDTH_SENSORS,
+ CONF_BLOCK_CLIENT,
+ CONF_CONTROLLER,
+ CONF_DETECTION_TIME,
+ CONF_SITE_ID,
+ CONF_SSID_FILTER,
+ CONF_TRACK_CLIENTS,
+ CONF_TRACK_DEVICES,
+ CONF_TRACK_WIRED_CLIENTS,
+)
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
@@ -18,6 +29,8 @@ from .test_controller import setup_unifi_integration
from tests.common import MockConfigEntry
+CLIENTS = [{"mac": "00:00:00:00:00:01"}]
+
WLANS = [{"name": "SSID 1"}, {"name": "SSID 2"}]
@@ -28,7 +41,7 @@ async def test_flow_works(hass, aioclient_mock, mock_discovery):
config_flow.DOMAIN, context={"source": "user"}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["data_schema"]({CONF_USERNAME: "", CONF_PASSWORD: ""}) == {
CONF_HOST: "unifi",
@@ -64,7 +77,7 @@ async def test_flow_works(hass, aioclient_mock, mock_discovery):
},
)
- assert result["type"] == "create_entry"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "Site name"
assert result["data"] == {
CONF_CONTROLLER: {
@@ -84,7 +97,7 @@ async def test_flow_works_multiple_sites(hass, aioclient_mock):
config_flow.DOMAIN, context={"source": "user"}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
aioclient_mock.post(
@@ -116,7 +129,7 @@ async def test_flow_works_multiple_sites(hass, aioclient_mock):
},
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "site"
assert result["data_schema"]({"site": "site name"})
assert result["data_schema"]({"site": "site2 name"})
@@ -133,7 +146,7 @@ async def test_flow_fails_site_already_configured(hass, aioclient_mock):
config_flow.DOMAIN, context={"source": "user"}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
aioclient_mock.post(
@@ -162,7 +175,7 @@ async def test_flow_fails_site_already_configured(hass, aioclient_mock):
},
)
- assert result["type"] == "abort"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
async def test_flow_fails_user_credentials_faulty(hass, aioclient_mock):
@@ -171,7 +184,7 @@ async def test_flow_fails_user_credentials_faulty(hass, aioclient_mock):
config_flow.DOMAIN, context={"source": "user"}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
with patch("aiounifi.Controller.login", side_effect=aiounifi.errors.Unauthorized):
@@ -186,7 +199,7 @@ async def test_flow_fails_user_credentials_faulty(hass, aioclient_mock):
},
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "faulty_credentials"}
@@ -196,7 +209,7 @@ async def test_flow_fails_controller_unavailable(hass, aioclient_mock):
config_flow.DOMAIN, context={"source": "user"}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
with patch("aiounifi.Controller.login", side_effect=aiounifi.errors.RequestError):
@@ -211,7 +224,7 @@ async def test_flow_fails_controller_unavailable(hass, aioclient_mock):
},
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "service_unavailable"}
@@ -221,7 +234,7 @@ async def test_flow_fails_unknown_problem(hass, aioclient_mock):
config_flow.DOMAIN, context={"source": "user"}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
with patch("aiounifi.Controller.login", side_effect=Exception):
@@ -236,12 +249,14 @@ async def test_flow_fails_unknown_problem(hass, aioclient_mock):
},
)
- assert result["type"] == "abort"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
async def test_option_flow(hass):
"""Test config flow options."""
- controller = await setup_unifi_integration(hass, wlans_response=WLANS)
+ controller = await setup_unifi_integration(
+ hass, clients_response=CLIENTS, wlans_response=WLANS
+ )
result = await hass.config_entries.options.async_init(
controller.config_entry.entry_id
@@ -253,27 +268,64 @@ async def test_option_flow(hass):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
- config_flow.CONF_TRACK_CLIENTS: False,
- config_flow.CONF_TRACK_WIRED_CLIENTS: False,
- config_flow.CONF_TRACK_DEVICES: False,
- config_flow.CONF_SSID_FILTER: ["SSID 1"],
- config_flow.CONF_DETECTION_TIME: 100,
+ CONF_TRACK_CLIENTS: False,
+ CONF_TRACK_WIRED_CLIENTS: False,
+ CONF_TRACK_DEVICES: False,
+ CONF_SSID_FILTER: ["SSID 1"],
+ CONF_DETECTION_TIME: 100,
},
)
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "client_control"
+
+ clients_to_block = hass.config_entries.options._progress[result["flow_id"]].options[
+ CONF_BLOCK_CLIENT
+ ]
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={
+ CONF_BLOCK_CLIENT: clients_to_block,
+ CONF_NEW_CLIENT: "00:00:00:00:00:01",
+ },
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "client_control"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={
+ CONF_BLOCK_CLIENT: clients_to_block,
+ CONF_NEW_CLIENT: "00:00:00:00:00:02",
+ },
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "client_control"
+ assert result["errors"] == {"base": "unknown_client_mac"}
+
+ clients_to_block = hass.config_entries.options._progress[result["flow_id"]].options[
+ CONF_BLOCK_CLIENT
+ ]
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={CONF_BLOCK_CLIENT: clients_to_block},
+ )
+
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "statistics_sensors"
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={config_flow.CONF_ALLOW_BANDWIDTH_SENSORS: True}
+ result["flow_id"], user_input={CONF_ALLOW_BANDWIDTH_SENSORS: True}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"] == {
- config_flow.CONF_TRACK_CLIENTS: False,
- config_flow.CONF_TRACK_WIRED_CLIENTS: False,
- config_flow.CONF_TRACK_DEVICES: False,
- config_flow.CONF_DETECTION_TIME: 100,
- config_flow.CONF_SSID_FILTER: ["SSID 1"],
- config_flow.CONF_ALLOW_BANDWIDTH_SENSORS: True,
+ CONF_TRACK_CLIENTS: False,
+ CONF_TRACK_WIRED_CLIENTS: False,
+ CONF_TRACK_DEVICES: False,
+ CONF_DETECTION_TIME: 100,
+ CONF_SSID_FILTER: ["SSID 1"],
+ CONF_BLOCK_CLIENT: ["00:00:00:00:00:01"],
+ CONF_ALLOW_BANDWIDTH_SENSORS: True,
}
diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py
index daec8cddf5d..8bf2225d1f1 100644
--- a/tests/components/unifi/test_controller.py
+++ b/tests/components/unifi/test_controller.py
@@ -166,7 +166,7 @@ async def test_controller_setup(hass):
controller.option_allow_bandwidth_sensors
== unifi.const.DEFAULT_ALLOW_BANDWIDTH_SENSORS
)
- assert controller.option_block_clients == unifi.const.DEFAULT_BLOCK_CLIENTS
+ assert isinstance(controller.option_block_clients, list)
assert controller.option_track_clients == unifi.const.DEFAULT_TRACK_CLIENTS
assert controller.option_track_devices == unifi.const.DEFAULT_TRACK_DEVICES
assert (
@@ -175,7 +175,7 @@ async def test_controller_setup(hass):
assert controller.option_detection_time == timedelta(
seconds=unifi.const.DEFAULT_DETECTION_TIME
)
- assert controller.option_ssid_filter == unifi.const.DEFAULT_SSID_FILTER
+ assert isinstance(controller.option_ssid_filter, list)
assert controller.mac is None
@@ -235,7 +235,7 @@ async def test_reset_after_successful_setup(hass):
"""Calling reset when the entry has been setup."""
controller = await setup_unifi_integration(hass)
- assert len(controller.listeners) == 5
+ assert len(controller.listeners) == 6
result = await controller.async_reset()
await hass.async_block_till_done()
diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py
index cbef7c31922..f225ddb44a9 100644
--- a/tests/components/unifi/test_device_tracker.py
+++ b/tests/components/unifi/test_device_tracker.py
@@ -108,7 +108,7 @@ async def test_no_clients(hass):
"""Test the update_clients function when no clients are found."""
await setup_unifi_integration(hass)
- assert len(hass.states.async_all()) == 1
+ assert len(hass.states.async_all()) == 0
async def test_tracked_devices(hass):
@@ -123,7 +123,7 @@ async def test_tracked_devices(hass):
devices_response=[DEVICE_1, DEVICE_2],
known_wireless_clients=(CLIENT_4["mac"],),
)
- assert len(hass.states.async_all()) == 7
+ assert len(hass.states.async_all()) == 6
client_1 = hass.states.get("device_tracker.client_1")
assert client_1 is not None
@@ -184,7 +184,7 @@ async def test_controller_state_change(hass):
controller = await setup_unifi_integration(
hass, clients_response=[CLIENT_1], devices_response=[DEVICE_1],
)
- assert len(hass.states.async_all()) == 3
+ assert len(hass.states.async_all()) == 2
# Controller unavailable
controller.async_unifi_signalling_callback(
@@ -214,7 +214,7 @@ async def test_option_track_clients(hass):
controller = await setup_unifi_integration(
hass, clients_response=[CLIENT_1, CLIENT_2], devices_response=[DEVICE_1],
)
- assert len(hass.states.async_all()) == 4
+ assert len(hass.states.async_all()) == 3
client_1 = hass.states.get("device_tracker.client_1")
assert client_1 is not None
@@ -259,7 +259,7 @@ async def test_option_track_wired_clients(hass):
controller = await setup_unifi_integration(
hass, clients_response=[CLIENT_1, CLIENT_2], devices_response=[DEVICE_1],
)
- assert len(hass.states.async_all()) == 4
+ assert len(hass.states.async_all()) == 3
client_1 = hass.states.get("device_tracker.client_1")
assert client_1 is not None
@@ -304,7 +304,7 @@ async def test_option_track_devices(hass):
controller = await setup_unifi_integration(
hass, clients_response=[CLIENT_1, CLIENT_2], devices_response=[DEVICE_1],
)
- assert len(hass.states.async_all()) == 4
+ assert len(hass.states.async_all()) == 3
client_1 = hass.states.get("device_tracker.client_1")
assert client_1 is not None
@@ -349,7 +349,7 @@ 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_all()) == 2
+ assert len(hass.states.async_all()) == 1
# SSID filter active
client_3 = hass.states.get("device_tracker.client_3")
@@ -387,7 +387,7 @@ async def test_wireless_client_go_wired_issue(hass):
client_1_client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow())
controller = await setup_unifi_integration(hass, clients_response=[client_1_client])
- assert len(hass.states.async_all()) == 2
+ assert len(hass.states.async_all()) == 1
client_1 = hass.states.get("device_tracker.client_1")
assert client_1 is not None
@@ -460,7 +460,7 @@ async def test_restoring_client(hass):
clients_response=[CLIENT_2],
clients_all_response=[CLIENT_1],
)
- assert len(hass.states.async_all()) == 3
+ assert len(hass.states.async_all()) == 2
device_1 = hass.states.get("device_tracker.client_1")
assert device_1 is not None
@@ -474,7 +474,7 @@ async def test_dont_track_clients(hass):
clients_response=[CLIENT_1],
devices_response=[DEVICE_1],
)
- assert len(hass.states.async_all()) == 2
+ assert len(hass.states.async_all()) == 1
client_1 = hass.states.get("device_tracker.client_1")
assert client_1 is None
@@ -492,7 +492,7 @@ async def test_dont_track_devices(hass):
clients_response=[CLIENT_1],
devices_response=[DEVICE_1],
)
- assert len(hass.states.async_all()) == 2
+ assert len(hass.states.async_all()) == 1
client_1 = hass.states.get("device_tracker.client_1")
assert client_1 is not None
@@ -509,7 +509,7 @@ async def test_dont_track_wired_clients(hass):
options={unifi.controller.CONF_TRACK_WIRED_CLIENTS: False},
clients_response=[CLIENT_1, CLIENT_2],
)
- assert len(hass.states.async_all()) == 2
+ assert len(hass.states.async_all()) == 1
client_1 = hass.states.get("device_tracker.client_1")
assert client_1 is not None
diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py
index 7d0600f5885..a858bc9a649 100644
--- a/tests/components/unifi/test_sensor.py
+++ b/tests/components/unifi/test_sensor.py
@@ -55,7 +55,7 @@ async def test_no_clients(hass):
)
assert len(controller.mock_requests) == 4
- assert len(hass.states.async_all()) == 1
+ assert len(hass.states.async_all()) == 0
async def test_sensors(hass):
@@ -71,7 +71,7 @@ async def test_sensors(hass):
)
assert len(controller.mock_requests) == 4
- assert len(hass.states.async_all()) == 5
+ assert len(hass.states.async_all()) == 4
wired_client_rx = hass.states.get("sensor.wired_client_name_rx")
assert wired_client_rx.state == "1234.0"
diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py
index a2b609078de..a06be14024b 100644
--- a/tests/components/unifi/test_switch.py
+++ b/tests/components/unifi/test_switch.py
@@ -4,6 +4,11 @@ from copy import deepcopy
from homeassistant import config_entries
from homeassistant.components import unifi
import homeassistant.components.switch as switch
+from homeassistant.components.unifi.const import (
+ CONF_BLOCK_CLIENT,
+ CONF_TRACK_CLIENTS,
+ CONF_TRACK_DEVICES,
+)
from homeassistant.helpers import entity_registry
from homeassistant.setup import async_setup_component
@@ -200,31 +205,24 @@ async def test_platform_manually_configured(hass):
async def test_no_clients(hass):
"""Test the update_clients function when no clients are found."""
controller = await setup_unifi_integration(
- hass,
- options={
- unifi.const.CONF_TRACK_CLIENTS: False,
- unifi.const.CONF_TRACK_DEVICES: False,
- },
+ hass, options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False},
)
assert len(controller.mock_requests) == 4
- assert len(hass.states.async_all()) == 1
+ assert len(hass.states.async_all()) == 0
async def test_controller_not_client(hass):
"""Test that the controller doesn't become a switch."""
controller = await setup_unifi_integration(
hass,
- options={
- unifi.const.CONF_TRACK_CLIENTS: False,
- unifi.const.CONF_TRACK_DEVICES: False,
- },
+ options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False},
clients_response=[CONTROLLER_HOST],
devices_response=[DEVICE_1],
)
assert len(controller.mock_requests) == 4
- assert len(hass.states.async_all()) == 1
+ assert len(hass.states.async_all()) == 0
cloudkey = hass.states.get("switch.cloud_key")
assert cloudkey is None
@@ -235,17 +233,14 @@ async def test_not_admin(hass):
sites["Site name"]["role"] = "not admin"
controller = await setup_unifi_integration(
hass,
- options={
- unifi.const.CONF_TRACK_CLIENTS: False,
- unifi.const.CONF_TRACK_DEVICES: False,
- },
+ options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False},
sites=sites,
clients_response=[CLIENT_1],
devices_response=[DEVICE_1],
)
assert len(controller.mock_requests) == 4
- assert len(hass.states.async_all()) == 1
+ assert len(hass.states.async_all()) == 0
async def test_switches(hass):
@@ -253,9 +248,9 @@ async def test_switches(hass):
controller = await setup_unifi_integration(
hass,
options={
- unifi.CONF_BLOCK_CLIENT: [BLOCKED["mac"], UNBLOCKED["mac"]],
- unifi.const.CONF_TRACK_CLIENTS: False,
- unifi.const.CONF_TRACK_DEVICES: False,
+ CONF_BLOCK_CLIENT: [BLOCKED["mac"], UNBLOCKED["mac"]],
+ CONF_TRACK_CLIENTS: False,
+ CONF_TRACK_DEVICES: False,
},
clients_response=[CLIENT_1, CLIENT_4],
devices_response=[DEVICE_1],
@@ -263,7 +258,7 @@ async def test_switches(hass):
)
assert len(controller.mock_requests) == 4
- assert len(hass.states.async_all()) == 4
+ assert len(hass.states.async_all()) == 3
switch_1 = hass.states.get("switch.poe_client_1")
assert switch_1 is not None
@@ -284,34 +279,10 @@ async def test_switches(hass):
assert unblocked is not None
assert unblocked.state == "on"
-
-async def test_new_client_discovered_on_block_control(hass):
- """Test if 2nd update has a new client."""
- controller = await setup_unifi_integration(
- hass,
- options={
- unifi.CONF_BLOCK_CLIENT: [BLOCKED["mac"]],
- unifi.const.CONF_TRACK_CLIENTS: False,
- unifi.const.CONF_TRACK_DEVICES: False,
- },
- clients_all_response=[BLOCKED],
- )
-
- assert len(controller.mock_requests) == 4
- assert len(hass.states.async_all()) == 2
-
- controller.api.websocket._data = {
- "meta": {"message": "sta:sync"},
- "data": [BLOCKED],
- }
- controller.api.session_handler("data")
-
- # Calling a service will trigger the updates to run
await hass.services.async_call(
"switch", "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True
)
assert len(controller.mock_requests) == 5
- assert len(hass.states.async_all()) == 2
assert controller.mock_requests[4] == {
"json": {"mac": "00:00:00:00:01:01", "cmd": "block-sta"},
"method": "post",
@@ -329,20 +300,85 @@ async def test_new_client_discovered_on_block_control(hass):
}
-async def test_new_client_discovered_on_poe_control(hass):
+async def test_new_client_discovered_on_block_control(hass):
"""Test if 2nd update has a new client."""
controller = await setup_unifi_integration(
hass,
options={
- unifi.const.CONF_TRACK_CLIENTS: False,
- unifi.const.CONF_TRACK_DEVICES: False,
+ CONF_BLOCK_CLIENT: [BLOCKED["mac"]],
+ CONF_TRACK_CLIENTS: False,
+ CONF_TRACK_DEVICES: False,
},
+ )
+
+ assert len(controller.mock_requests) == 4
+ assert len(hass.states.async_all()) == 0
+
+ blocked = hass.states.get("switch.block_client_1")
+ assert blocked is None
+
+ controller.api.websocket._data = {
+ "meta": {"message": "sta:sync"},
+ "data": [BLOCKED],
+ }
+ controller.api.session_handler("data")
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+ blocked = hass.states.get("switch.block_client_1")
+ assert blocked is not None
+
+
+async def test_option_block_clients(hass):
+ """Test the changes to option reflects accordingly."""
+ controller = await setup_unifi_integration(
+ hass,
+ options={CONF_BLOCK_CLIENT: [BLOCKED["mac"]]},
+ clients_all_response=[BLOCKED, UNBLOCKED],
+ )
+ assert len(hass.states.async_all()) == 1
+
+ # Add a second switch
+ hass.config_entries.async_update_entry(
+ controller.config_entry,
+ options={CONF_BLOCK_CLIENT: [BLOCKED["mac"], UNBLOCKED["mac"]]},
+ )
+ await hass.async_block_till_done()
+ assert len(hass.states.async_all()) == 2
+
+ # Remove the second switch again
+ hass.config_entries.async_update_entry(
+ controller.config_entry, options={CONF_BLOCK_CLIENT: [BLOCKED["mac"]]},
+ )
+ await hass.async_block_till_done()
+ assert len(hass.states.async_all()) == 1
+
+ # Enable one and remove another one
+ hass.config_entries.async_update_entry(
+ controller.config_entry, options={CONF_BLOCK_CLIENT: [UNBLOCKED["mac"]]},
+ )
+ await hass.async_block_till_done()
+ assert len(hass.states.async_all()) == 1
+
+ # Remove one
+ hass.config_entries.async_update_entry(
+ controller.config_entry, options={CONF_BLOCK_CLIENT: []},
+ )
+ await hass.async_block_till_done()
+ assert len(hass.states.async_all()) == 0
+
+
+async def test_new_client_discovered_on_poe_control(hass):
+ """Test if 2nd update has a new client."""
+ controller = await setup_unifi_integration(
+ hass,
+ options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False},
clients_response=[CLIENT_1],
devices_response=[DEVICE_1],
)
assert len(controller.mock_requests) == 4
- assert len(hass.states.async_all()) == 2
+ assert len(hass.states.async_all()) == 1
controller.api.websocket._data = {
"meta": {"message": "sta:sync"},
@@ -355,7 +391,7 @@ async def test_new_client_discovered_on_poe_control(hass):
"switch", "turn_off", {"entity_id": "switch.poe_client_1"}, blocking=True
)
assert len(controller.mock_requests) == 5
- assert len(hass.states.async_all()) == 3
+ assert len(hass.states.async_all()) == 2
assert controller.mock_requests[4] == {
"json": {
"port_overrides": [{"port_idx": 1, "portconf_id": "1a1", "poe_mode": "off"}]
@@ -394,7 +430,7 @@ async def test_ignore_multiple_poe_clients_on_same_port(hass):
)
assert len(controller.mock_requests) == 4
- assert len(hass.states.async_all()) == 4
+ assert len(hass.states.async_all()) == 3
switch_1 = hass.states.get("switch.poe_client_1")
switch_2 = hass.states.get("switch.poe_client_2")
@@ -435,9 +471,9 @@ async def test_restoring_client(hass):
controller = await setup_unifi_integration(
hass,
options={
- unifi.CONF_BLOCK_CLIENT: ["random mac"],
- unifi.const.CONF_TRACK_CLIENTS: False,
- unifi.const.CONF_TRACK_DEVICES: False,
+ CONF_BLOCK_CLIENT: ["random mac"],
+ CONF_TRACK_CLIENTS: False,
+ CONF_TRACK_DEVICES: False,
},
clients_response=[CLIENT_2],
devices_response=[DEVICE_1],
@@ -445,7 +481,7 @@ async def test_restoring_client(hass):
)
assert len(controller.mock_requests) == 4
- assert len(hass.states.async_all()) == 3
+ assert len(hass.states.async_all()) == 2
device_1 = hass.states.get("switch.client_1")
assert device_1 is not None
diff --git a/tests/components/unifi_direct/test_device_tracker.py b/tests/components/unifi_direct/test_device_tracker.py
index 84602f438f4..297bf917dbf 100644
--- a/tests/components/unifi_direct/test_device_tracker.py
+++ b/tests/components/unifi_direct/test_device_tracker.py
@@ -7,7 +7,6 @@ import pytest
import voluptuous as vol
from homeassistant.components.device_tracker import (
- CONF_AWAY_HIDE,
CONF_CONSIDER_HOME,
CONF_NEW_DEVICE_DEFAULTS,
CONF_TRACK_NEW,
@@ -49,7 +48,7 @@ async def test_get_scanner(unifi_mock, hass):
CONF_PASSWORD: "fake_pass",
CONF_TRACK_NEW: True,
CONF_CONSIDER_HOME: timedelta(seconds=180),
- CONF_NEW_DEVICE_DEFAULTS: {CONF_TRACK_NEW: True, CONF_AWAY_HIDE: False},
+ CONF_NEW_DEVICE_DEFAULTS: {CONF_TRACK_NEW: True},
}
}
diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py
index cf985621351..cf3fc8fcb33 100644
--- a/tests/components/universal/test_media_player.py
+++ b/tests/components/universal/test_media_player.py
@@ -184,13 +184,13 @@ class TestMediaPlayer(unittest.TestCase):
self.mock_state_switch_id = switch.ENTITY_ID_FORMAT.format("state")
self.hass.states.set(self.mock_state_switch_id, STATE_OFF)
- self.mock_volume_id = input_number.ENTITY_ID_FORMAT.format("volume_level")
+ self.mock_volume_id = f"{input_number.DOMAIN}.volume_level"
self.hass.states.set(self.mock_volume_id, 0)
- self.mock_source_list_id = input_select.ENTITY_ID_FORMAT.format("source_list")
+ self.mock_source_list_id = f"{input_select.DOMAIN}.source_list"
self.hass.states.set(self.mock_source_list_id, ["dvd", "htpc"])
- self.mock_source_id = input_select.ENTITY_ID_FORMAT.format("source")
+ self.mock_source_id = f"{input_select.DOMAIN}.source"
self.hass.states.set(self.mock_source_id, "dvd")
self.mock_shuffle_switch_id = switch.ENTITY_ID_FORMAT.format("shuffle")
diff --git a/tests/components/updater/test_init.py b/tests/components/updater/test_init.py
index 10fa026db29..e583f4e0114 100644
--- a/tests/components/updater/test_init.py
+++ b/tests/components/updater/test_init.py
@@ -1,5 +1,4 @@
"""The tests for the Updater component."""
-import asyncio
from unittest.mock import Mock
from asynctest import patch
@@ -130,17 +129,6 @@ async def test_get_newest_version_analytics_when_huuid(hass, aioclient_mock):
assert res == (MOCK_RESPONSE["version"], MOCK_RESPONSE["release-notes"])
-async def test_error_fetching_new_version_timeout(hass):
- """Test we handle timeout error while fetching new version."""
- with patch(
- "homeassistant.helpers.system_info.async_get_system_info",
- Mock(return_value=mock_coro({"fake": "bla"})),
- ), patch("async_timeout.timeout", side_effect=asyncio.TimeoutError), pytest.raises(
- UpdateFailed
- ):
- await updater.get_newest_version(hass, MOCK_HUUID, False)
-
-
async def test_error_fetching_new_version_bad_json(hass, aioclient_mock):
"""Test we handle json error while fetching new version."""
aioclient_mock.post(updater.UPDATER_URL, text="not json")
diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py
index fcfe97804e4..19742d74f21 100644
--- a/tests/components/utility_meter/test_sensor.py
+++ b/tests/components/utility_meter/test_sensor.py
@@ -7,7 +7,9 @@ from unittest.mock import patch
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.utility_meter.const import (
ATTR_TARIFF,
+ ATTR_VALUE,
DOMAIN,
+ SERVICE_CALIBRATE_METER,
SERVICE_SELECT_TARIFF,
)
from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START
@@ -96,6 +98,17 @@ async def test_state(hass):
assert state is not None
assert state.state == "3"
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_CALIBRATE_METER,
+ {ATTR_ENTITY_ID: "sensor.energy_bill_midpeak", ATTR_VALUE: "100"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ state = hass.states.get("sensor.energy_bill_midpeak")
+ assert state is not None
+ assert state.state == "100"
+
async def test_net_consumption(hass):
"""Test utility sensor state."""
diff --git a/tests/components/vera/test_sensor.py b/tests/components/vera/test_sensor.py
index da28fc225e0..9e84815d636 100644
--- a/tests/components/vera/test_sensor.py
+++ b/tests/components/vera/test_sensor.py
@@ -13,6 +13,7 @@ from pyvera import (
VeraSensor,
)
+from homeassistant.const import UNIT_PERCENTAGE
from homeassistant.core import HomeAssistant
from .common import ComponentFactory
@@ -64,7 +65,7 @@ async def test_temperature_sensor_f(
vera_component_factory=vera_component_factory,
category=CATEGORY_TEMPERATURE_SENSOR,
class_property="temperature",
- assert_states=(("33", "1"), ("44", "7"),),
+ assert_states=(("33", "1"), ("44", "7")),
setup_callback=setup_callback,
)
@@ -78,7 +79,7 @@ async def test_temperature_sensor_c(
vera_component_factory=vera_component_factory,
category=CATEGORY_TEMPERATURE_SENSOR,
class_property="temperature",
- assert_states=(("33", "33"), ("44", "44"),),
+ assert_states=(("33", "33"), ("44", "44")),
)
@@ -91,7 +92,7 @@ async def test_light_sensor(
vera_component_factory=vera_component_factory,
category=CATEGORY_LIGHT_SENSOR,
class_property="light",
- assert_states=(("12", "12"), ("13", "13"),),
+ assert_states=(("12", "12"), ("13", "13")),
assert_unit_of_measurement="lx",
)
@@ -105,7 +106,7 @@ async def test_uv_sensor(
vera_component_factory=vera_component_factory,
category=CATEGORY_UV_SENSOR,
class_property="light",
- assert_states=(("12", "12"), ("13", "13"),),
+ assert_states=(("12", "12"), ("13", "13")),
assert_unit_of_measurement="level",
)
@@ -119,8 +120,8 @@ async def test_humidity_sensor(
vera_component_factory=vera_component_factory,
category=CATEGORY_HUMIDITY_SENSOR,
class_property="humidity",
- assert_states=(("12", "12"), ("13", "13"),),
- assert_unit_of_measurement="%",
+ assert_states=(("12", "12"), ("13", "13")),
+ assert_unit_of_measurement=UNIT_PERCENTAGE,
)
@@ -133,7 +134,7 @@ async def test_power_meter_sensor(
vera_component_factory=vera_component_factory,
category=CATEGORY_POWER_METER,
class_property="power",
- assert_states=(("12", "12"), ("13", "13"),),
+ assert_states=(("12", "12"), ("13", "13")),
assert_unit_of_measurement="watts",
)
@@ -151,7 +152,7 @@ async def test_trippable_sensor(
vera_component_factory=vera_component_factory,
category=999,
class_property="is_tripped",
- assert_states=((True, "Tripped"), (False, "Not Tripped"), (True, "Tripped"),),
+ assert_states=((True, "Tripped"), (False, "Not Tripped"), (True, "Tripped")),
setup_callback=setup_callback,
)
@@ -169,7 +170,7 @@ async def test_unknown_sensor(
vera_component_factory=vera_component_factory,
category=999,
class_property="is_tripped",
- assert_states=((True, "Unknown"), (False, "Unknown"), (True, "Unknown"),),
+ assert_states=((True, "Unknown"), (False, "Unknown"), (True, "Unknown")),
setup_callback=setup_callback,
)
@@ -187,7 +188,7 @@ async def test_scene_controller_sensor(
entity_id = "sensor.dev1_1"
component_data = await vera_component_factory.configure_component(
- hass=hass, devices=(vera_device,),
+ hass=hass, devices=(vera_device,)
)
controller = component_data.controller
update_callback = controller.register.call_args_list[0][0][1]
diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py
index 581ea7cdd5c..ab581bdf3c6 100644
--- a/tests/components/vizio/conftest.py
+++ b/tests/components/vizio/conftest.py
@@ -3,7 +3,21 @@ from asynctest import patch
import pytest
from pyvizio.const import DEVICE_CLASS_SPEAKER, MAX_VOLUME
-from .const import CURRENT_INPUT, INPUT_LIST, MODEL, UNIQUE_ID, VERSION
+from .const import (
+ ACCESS_TOKEN,
+ APP_LIST,
+ CH_TYPE,
+ CURRENT_APP,
+ CURRENT_INPUT,
+ INPUT_LIST,
+ INPUT_LIST_WITH_APPS,
+ MODEL,
+ RESPONSE_TOKEN,
+ UNIQUE_ID,
+ VERSION,
+ MockCompletePairingResponse,
+ MockStartPairingResponse,
+)
class MockInput:
@@ -42,6 +56,41 @@ def vizio_connect_fixture():
yield
+@pytest.fixture(name="vizio_complete_pairing")
+def vizio_complete_pairing_fixture():
+ """Mock complete vizio pairing workflow."""
+ with patch(
+ "homeassistant.components.vizio.config_flow.VizioAsync.start_pair",
+ return_value=MockStartPairingResponse(CH_TYPE, RESPONSE_TOKEN),
+ ), patch(
+ "homeassistant.components.vizio.config_flow.VizioAsync.pair",
+ return_value=MockCompletePairingResponse(ACCESS_TOKEN),
+ ):
+ yield
+
+
+@pytest.fixture(name="vizio_start_pairing_failure")
+def vizio_start_pairing_failure_fixture():
+ """Mock vizio start pairing failure."""
+ with patch(
+ "homeassistant.components.vizio.config_flow.VizioAsync.start_pair",
+ return_value=None,
+ ):
+ yield
+
+
+@pytest.fixture(name="vizio_invalid_pin_failure")
+def vizio_invalid_pin_failure_fixture():
+ """Mock vizio failure due to invalid pin."""
+ with patch(
+ "homeassistant.components.vizio.config_flow.VizioAsync.start_pair",
+ return_value=MockStartPairingResponse(CH_TYPE, RESPONSE_TOKEN),
+ ), patch(
+ "homeassistant.components.vizio.config_flow.VizioAsync.pair", return_value=None,
+ ):
+ yield
+
+
@pytest.fixture(name="vizio_bypass_setup")
def vizio_bypass_setup_fixture():
"""Mock component setup."""
@@ -53,7 +102,7 @@ def vizio_bypass_setup_fixture():
def vizio_bypass_update_fixture():
"""Mock component update."""
with patch(
- "homeassistant.components.vizio.media_player.VizioAsync.can_connect",
+ "homeassistant.components.vizio.media_player.VizioAsync.can_connect_with_auth_check",
return_value=True,
), patch("homeassistant.components.vizio.media_player.VizioDevice.async_update"):
yield
@@ -71,7 +120,7 @@ def vizio_guess_device_type_fixture():
@pytest.fixture(name="vizio_cant_connect")
def vizio_cant_connect_fixture():
- """Mock vizio device can't connect."""
+ """Mock vizio device can't connect with valid auth."""
with patch(
"homeassistant.components.vizio.config_flow.VizioAsync.validate_ha_config",
return_value=False,
@@ -83,11 +132,14 @@ def vizio_cant_connect_fixture():
def vizio_update_fixture():
"""Mock valid updates to vizio device."""
with patch(
- "homeassistant.components.vizio.media_player.VizioAsync.can_connect",
+ "homeassistant.components.vizio.media_player.VizioAsync.can_connect_with_auth_check",
return_value=True,
), patch(
- "homeassistant.components.vizio.media_player.VizioAsync.get_current_volume",
- return_value=int(MAX_VOLUME[DEVICE_CLASS_SPEAKER] / 2),
+ "homeassistant.components.vizio.media_player.VizioAsync.get_all_audio_settings",
+ return_value={
+ "volume": int(MAX_VOLUME[DEVICE_CLASS_SPEAKER] / 2),
+ "mute": "Off",
+ },
), patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_input",
return_value=CURRENT_INPUT,
@@ -98,10 +150,29 @@ def vizio_update_fixture():
"homeassistant.components.vizio.media_player.VizioAsync.get_power_state",
return_value=True,
), patch(
- "homeassistant.components.vizio.media_player.VizioAsync.get_model",
+ "homeassistant.components.vizio.media_player.VizioAsync.get_model_name",
return_value=MODEL,
), patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_version",
return_value=VERSION,
):
yield
+
+
+@pytest.fixture(name="vizio_update_with_apps")
+def vizio_update_with_apps_fixture(vizio_update: pytest.fixture):
+ """Mock valid updates to vizio device that supports apps."""
+ with patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.get_inputs_list",
+ return_value=get_mock_inputs(INPUT_LIST_WITH_APPS),
+ ), patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.get_apps_list",
+ return_value=APP_LIST,
+ ), patch(
+ "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,
+ ):
+ yield
diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py
index 537db445a85..2cb9103c4d9 100644
--- a/tests/components/vizio/const.py
+++ b/tests/components/vizio/const.py
@@ -6,12 +6,25 @@ from homeassistant.components.media_player import (
DEVICE_CLASS_TV,
DOMAIN as MP_DOMAIN,
)
-from homeassistant.components.vizio.const import CONF_VOLUME_STEP
+from homeassistant.components.vizio.const import (
+ CONF_ADDITIONAL_CONFIGS,
+ CONF_APP_ID,
+ CONF_APPS,
+ CONF_APPS_TO_INCLUDE_OR_EXCLUDE,
+ CONF_CONFIG,
+ CONF_INCLUDE_OR_EXCLUDE,
+ CONF_MESSAGE,
+ CONF_NAME_SPACE,
+ CONF_VOLUME_STEP,
+)
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_DEVICE_CLASS,
+ CONF_EXCLUDE,
CONF_HOST,
+ CONF_INCLUDE,
CONF_NAME,
+ CONF_PIN,
CONF_PORT,
CONF_TYPE,
)
@@ -29,6 +42,46 @@ UNIQUE_ID = "testid"
MODEL = "model"
VERSION = "version"
+CH_TYPE = 1
+RESPONSE_TOKEN = 1234
+PIN = "abcd"
+
+
+class MockStartPairingResponse(object):
+ """Mock Vizio start pairing response."""
+
+ def __init__(self, ch_type: int, token: int) -> None:
+ """Initialize mock start pairing response."""
+ self.ch_type = ch_type
+ self.token = token
+
+
+class MockCompletePairingResponse(object):
+ """Mock Vizio complete pairing response."""
+
+ def __init__(self, auth_token: str) -> None:
+ """Initialize mock complete pairing response."""
+ self.auth_token = auth_token
+
+
+CURRENT_INPUT = "HDMI"
+INPUT_LIST = ["HDMI", "USB", "Bluetooth", "AUX"]
+
+CURRENT_APP = "Hulu"
+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,
+ CONF_CONFIG: CUSTOM_CONFIG,
+}
+
+ENTITY_ID = f"{MP_DOMAIN}.{slugify(NAME)}"
+
+
+MOCK_PIN_CONFIG = {CONF_PIN: PIN}
+
MOCK_USER_VALID_TV_CONFIG = {
CONF_NAME: NAME,
CONF_HOST: HOST,
@@ -48,7 +101,59 @@ MOCK_IMPORT_VALID_TV_CONFIG = {
CONF_VOLUME_STEP: VOLUME_STEP,
}
-MOCK_INVALID_TV_CONFIG = {
+MOCK_TV_WITH_INCLUDE_CONFIG = {
+ CONF_NAME: NAME,
+ CONF_HOST: HOST,
+ CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
+ CONF_ACCESS_TOKEN: ACCESS_TOKEN,
+ CONF_VOLUME_STEP: VOLUME_STEP,
+ CONF_APPS: {CONF_INCLUDE: [CURRENT_APP]},
+}
+
+MOCK_TV_WITH_EXCLUDE_CONFIG = {
+ CONF_NAME: NAME,
+ CONF_HOST: HOST,
+ CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
+ CONF_ACCESS_TOKEN: ACCESS_TOKEN,
+ CONF_VOLUME_STEP: VOLUME_STEP,
+ CONF_APPS: {CONF_EXCLUDE: ["Netflix"]},
+}
+
+MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG = {
+ CONF_NAME: NAME,
+ CONF_HOST: HOST,
+ CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
+ CONF_ACCESS_TOKEN: ACCESS_TOKEN,
+ CONF_VOLUME_STEP: VOLUME_STEP,
+ CONF_APPS: {CONF_ADDITIONAL_CONFIGS: [ADDITIONAL_APP_CONFIG]},
+}
+
+MOCK_SPEAKER_APPS_FAILURE = {
+ CONF_NAME: NAME,
+ CONF_HOST: HOST,
+ CONF_DEVICE_CLASS: DEVICE_CLASS_SPEAKER,
+ CONF_ACCESS_TOKEN: ACCESS_TOKEN,
+ CONF_VOLUME_STEP: VOLUME_STEP,
+ CONF_APPS: {CONF_ADDITIONAL_CONFIGS: [ADDITIONAL_APP_CONFIG]},
+}
+
+MOCK_TV_APPS_FAILURE = {
+ CONF_NAME: NAME,
+ CONF_HOST: HOST,
+ CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
+ CONF_ACCESS_TOKEN: ACCESS_TOKEN,
+ CONF_VOLUME_STEP: VOLUME_STEP,
+ CONF_APPS: None,
+}
+
+MOCK_TV_APPS_WITH_VALID_APPS_CONFIG = {
+ CONF_HOST: HOST,
+ CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
+ CONF_ACCESS_TOKEN: ACCESS_TOKEN,
+ CONF_APPS: {CONF_INCLUDE: [CURRENT_APP]},
+}
+
+MOCK_TV_CONFIG_NO_TOKEN = {
CONF_NAME: NAME,
CONF_HOST: HOST,
CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
@@ -60,6 +165,15 @@ MOCK_SPEAKER_CONFIG = {
CONF_DEVICE_CLASS: DEVICE_CLASS_SPEAKER,
}
+MOCK_INCLUDE_APPS = {
+ CONF_INCLUDE_OR_EXCLUDE: CONF_INCLUDE.title(),
+ CONF_APPS_TO_INCLUDE_OR_EXCLUDE: [CURRENT_APP],
+}
+MOCK_INCLUDE_NO_APPS = {
+ CONF_INCLUDE_OR_EXCLUDE: CONF_INCLUDE.title(),
+ CONF_APPS_TO_INCLUDE_OR_EXCLUDE: [],
+}
+
VIZIO_ZEROCONF_SERVICE_TYPE = "_viziocast._tcp.local."
ZEROCONF_NAME = f"{NAME}.{VIZIO_ZEROCONF_SERVICE_TYPE}"
ZEROCONF_HOST = HOST.split(":")[0]
@@ -72,8 +186,3 @@ MOCK_ZEROCONF_SERVICE_INFO = {
CONF_PORT: ZEROCONF_PORT,
"properties": {"name": "SB4031-D5"},
}
-
-CURRENT_INPUT = "HDMI"
-INPUT_LIST = ["HDMI", "USB", "Bluetooth", "AUX"]
-
-ENTITY_ID = f"{MP_DOMAIN}.{slugify(NAME)}"
diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py
index 9683ebd87b0..e773035447a 100644
--- a/tests/components/vizio/test_config_flow.py
+++ b/tests/components/vizio/test_config_flow.py
@@ -1,10 +1,17 @@
"""Tests for Vizio config flow."""
+import logging
+
import pytest
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.media_player import DEVICE_CLASS_SPEAKER, DEVICE_CLASS_TV
+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,
@@ -17,16 +24,22 @@ from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_HOST,
CONF_NAME,
+ CONF_PIN,
)
from homeassistant.helpers.typing import HomeAssistantType
from .const import (
ACCESS_TOKEN,
+ CURRENT_APP,
HOST,
HOST2,
MOCK_IMPORT_VALID_TV_CONFIG,
- MOCK_INVALID_TV_CONFIG,
+ MOCK_INCLUDE_APPS,
+ MOCK_INCLUDE_NO_APPS,
+ MOCK_PIN_CONFIG,
MOCK_SPEAKER_CONFIG,
+ MOCK_TV_CONFIG_NO_TOKEN,
+ MOCK_TV_WITH_EXCLUDE_CONFIG,
MOCK_USER_VALID_TV_CONFIG,
MOCK_ZEROCONF_SERVICE_INFO,
NAME,
@@ -37,6 +50,8 @@ from .const import (
from tests.common import MockConfigEntry
+_LOGGER = logging.getLogger(__name__)
+
async def test_user_flow_minimum_fields(
hass: HomeAssistantType,
@@ -80,12 +95,48 @@ 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]
+
+
+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:
@@ -197,7 +248,7 @@ async def test_user_esn_already_exists(
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
- assert result["reason"] == "already_setup_with_diff_host_and_name"
+ assert result["reason"] == "already_configured"
async def test_user_error_on_could_not_connect(
@@ -212,18 +263,76 @@ async def test_user_error_on_could_not_connect(
assert result["errors"] == {"base": "cant_connect"}
-async def test_user_error_on_tv_needs_token(
+async def test_user_tv_pairing_no_apps(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_bypass_setup: pytest.fixture,
+ vizio_complete_pairing: pytest.fixture,
) -> None:
- """Test when config fails custom validation for non null access token when device_class = tv during user setup."""
+ """Test pairing config flow when access token not provided for tv during user entry and no apps configured."""
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=MOCK_INVALID_TV_CONFIG
+ DOMAIN, context={"source": SOURCE_USER}, data=MOCK_TV_CONFIG_NO_TOKEN
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["errors"] == {"base": "tv_needs_token"}
+ assert result["step_id"] == "pair_tv"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=MOCK_PIN_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_NO_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 CONF_APPS not in result["data"]
+
+
+async def test_user_start_pairing_failure(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_bypass_setup: pytest.fixture,
+ vizio_start_pairing_failure: pytest.fixture,
+) -> None:
+ """Test failure to start pairing from user config flow."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=MOCK_TV_CONFIG_NO_TOKEN
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "user"
+ assert result["errors"] == {"base": "cant_connect"}
+
+
+async def test_user_invalid_pin(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_bypass_setup: pytest.fixture,
+ vizio_invalid_pin_failure: pytest.fixture,
+) -> None:
+ """Test failure to complete pairing from user config flow."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=MOCK_TV_CONFIG_NO_TOKEN
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "pair_tv"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=MOCK_PIN_CONFIG
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "pair_tv"
+ assert result["errors"] == {CONF_PIN: "complete_pairing_failed"}
async def test_import_flow_minimum_fields(
@@ -324,12 +433,12 @@ async def test_import_flow_update_options(
)
-async def test_import_flow_update_name(
+async def test_import_flow_update_name_and_apps(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_bypass_update: pytest.fixture,
) -> None:
- """Test import config flow with updated name."""
+ """Test import config flow with updated name and apps."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
@@ -343,6 +452,7 @@ async def test_import_flow_update_name(
updated_config = MOCK_IMPORT_VALID_TV_CONFIG.copy()
updated_config[CONF_NAME] = NAME2
+ updated_config[CONF_APPS] = {CONF_INCLUDE: [CURRENT_APP]}
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
@@ -352,6 +462,152 @@ async def test_import_flow_update_name(
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]
+ }
+
+
+async def test_import_flow_update_remove_apps(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_bypass_update: pytest.fixture,
+) -> None:
+ """Test import config flow with removed apps."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=vol.Schema(VIZIO_SCHEMA)(MOCK_TV_WITH_EXCLUDE_CONFIG),
+ )
+ await hass.async_block_till_done()
+
+ assert result["result"].data[CONF_NAME] == NAME
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ entry_id = result["result"].entry_id
+
+ updated_config = MOCK_TV_WITH_EXCLUDE_CONFIG.copy()
+ updated_config.pop(CONF_APPS)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=vol.Schema(VIZIO_SCHEMA)(updated_config),
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "updated_entry"
+ assert hass.config_entries.async_get_entry(entry_id).data.get(CONF_APPS) is None
+
+
+async def test_import_needs_pairing(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_bypass_setup: pytest.fixture,
+ vizio_complete_pairing: pytest.fixture,
+) -> None:
+ """Test pairing config flow when access token not provided for tv during import."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_TV_CONFIG_NO_TOKEN
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "user"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=MOCK_TV_CONFIG_NO_TOKEN
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "pair_tv"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=MOCK_PIN_CONFIG
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "pairing_complete_import"
+
+ 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
+ assert result["data"][CONF_NAME] == NAME
+ assert result["data"][CONF_HOST] == HOST
+ assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV
+
+
+async def test_import_with_apps_needs_pairing(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_bypass_setup: pytest.fixture,
+ vizio_complete_pairing: pytest.fixture,
+) -> None:
+ """Test pairing config flow when access token not provided for tv but apps are included during import."""
+ import_config = MOCK_TV_CONFIG_NO_TOKEN.copy()
+ import_config[CONF_APPS] = {CONF_INCLUDE: [CURRENT_APP]}
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=import_config
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "user"
+
+ # Mock inputting info without apps to make sure apps get stored
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input=_get_config_schema(MOCK_TV_CONFIG_NO_TOKEN)(MOCK_TV_CONFIG_NO_TOKEN),
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "pair_tv"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input=MOCK_PIN_CONFIG
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "pairing_complete_import"
+
+ 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
+ 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_APPS][CONF_INCLUDE] == [CURRENT_APP]
+
+
+async def test_import_error(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_bypass_setup: pytest.fixture,
+ caplog: pytest.fixture,
+) -> None:
+ """Test that error is logged when import config has an error."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG),
+ options={CONF_VOLUME_STEP: VOLUME_STEP},
+ )
+ entry.add_to_hass(hass)
+ fail_entry = MOCK_SPEAKER_CONFIG.copy()
+ fail_entry[CONF_HOST] = "0.0.0.0"
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=vol.Schema(VIZIO_SCHEMA)(fail_entry),
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+
+ # Ensure error gets logged
+ vizio_log_list = [
+ log
+ for log in caplog.records
+ if log.name == "homeassistant.components.vizio.config_flow"
+ ]
+ assert len(vizio_log_list) == 1
async def test_zeroconf_flow(
diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py
index bbbbca8c359..68366e8e98b 100644
--- a/tests/components/vizio/test_media_player.py
+++ b/tests/components/vizio/test_media_player.py
@@ -1,14 +1,21 @@
"""Tests for Vizio config flow."""
from datetime import timedelta
+import logging
+from typing import Any, Dict
from unittest.mock import call
from asynctest import patch
import pytest
+from pytest import raises
+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,
+ INPUT_APPS,
MAX_VOLUME,
+ UNKNOWN_APP,
)
+import voluptuous as vol
from homeassistant.components.media_player import (
ATTR_INPUT_SOURCE,
@@ -27,16 +34,41 @@ from homeassistant.components.media_player import (
SERVICE_VOLUME_SET,
SERVICE_VOLUME_UP,
)
-from homeassistant.components.vizio.const import CONF_VOLUME_STEP, DOMAIN
-from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE
+from homeassistant.components.vizio import validate_apps
+from homeassistant.components.vizio.const import (
+ CONF_ADDITIONAL_CONFIGS,
+ CONF_APPS,
+ CONF_VOLUME_STEP,
+ DOMAIN,
+ VIZIO_SCHEMA,
+)
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ CONF_EXCLUDE,
+ CONF_INCLUDE,
+ STATE_OFF,
+ STATE_ON,
+ STATE_UNAVAILABLE,
+)
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util import dt as dt_util
from .const import (
+ ADDITIONAL_APP_CONFIG,
+ APP_LIST,
+ CURRENT_APP,
CURRENT_INPUT,
+ CUSTOM_APP_NAME,
+ CUSTOM_CONFIG,
ENTITY_ID,
INPUT_LIST,
+ INPUT_LIST_WITH_APPS,
+ MOCK_SPEAKER_APPS_FAILURE,
MOCK_SPEAKER_CONFIG,
+ MOCK_TV_APPS_FAILURE,
+ MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG,
+ MOCK_TV_WITH_EXCLUDE_CONFIG,
+ MOCK_TV_WITH_INCLUDE_CONFIG,
MOCK_USER_VALID_TV_CONFIG,
NAME,
UNIQUE_ID,
@@ -45,6 +77,8 @@ from .const import (
from tests.common import MockConfigEntry, async_fire_time_changed
+_LOGGER = logging.getLogger(__name__)
+
async def _test_setup(
hass: HomeAssistantType, ha_device_class: str, vizio_power_state: bool
@@ -60,21 +94,27 @@ async def _test_setup(
if ha_device_class == DEVICE_CLASS_SPEAKER:
vizio_device_class = VIZIO_DEVICE_CLASS_SPEAKER
config_entry = MockConfigEntry(
- domain=DOMAIN, data=MOCK_SPEAKER_CONFIG, unique_id=UNIQUE_ID
+ domain=DOMAIN,
+ data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG),
+ unique_id=UNIQUE_ID,
)
else:
vizio_device_class = VIZIO_DEVICE_CLASS_TV
config_entry = MockConfigEntry(
- domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID
+ domain=DOMAIN,
+ data=vol.Schema(VIZIO_SCHEMA)(MOCK_USER_VALID_TV_CONFIG),
+ unique_id=UNIQUE_ID,
)
with patch(
- "homeassistant.components.vizio.media_player.VizioAsync.get_current_volume",
- return_value=int(MAX_VOLUME[vizio_device_class] / 2),
+ "homeassistant.components.vizio.media_player.VizioAsync.get_all_audio_settings",
+ return_value={"volume": int(MAX_VOLUME[vizio_device_class] / 2), "mute": "Off"},
), patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_power_state",
return_value=vizio_power_state,
- ):
+ ), patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.get_current_app",
+ ) as service_call:
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@@ -87,6 +127,8 @@ async def _test_setup(
if ha_power_state == STATE_ON:
assert attr["source_list"] == INPUT_LIST
assert attr["source"] == CURRENT_INPUT
+ if ha_device_class == DEVICE_CLASS_SPEAKER:
+ assert not service_call.called
assert (
attr["volume_level"]
== float(int(MAX_VOLUME[vizio_device_class] / 2))
@@ -94,10 +136,76 @@ async def _test_setup(
)
+async def _test_setup_with_apps(
+ hass: HomeAssistantType, device_config: Dict[str, Any], app: str
+) -> None:
+ """Test Vizio Device with apps entity setup."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN, data=vol.Schema(VIZIO_SCHEMA)(device_config), unique_id=UNIQUE_ID
+ )
+
+ with patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.get_all_audio_settings",
+ return_value={
+ "volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2),
+ "mute": "Off",
+ },
+ ), 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"]),
+ ):
+ config_entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ attr = hass.states.get(ENTITY_ID).attributes
+ assert attr["friendly_name"] == NAME
+ assert attr["device_class"] == DEVICE_CLASS_TV
+ assert hass.states.get(ENTITY_ID).state == STATE_ON
+
+ if device_config.get(CONF_APPS, {}).get(CONF_INCLUDE) or device_config.get(
+ CONF_APPS, {}
+ ).get(CONF_EXCLUDE):
+ list_to_test = list(INPUT_LIST_WITH_APPS + [CURRENT_APP])
+ elif device_config.get(CONF_APPS, {}).get(CONF_ADDITIONAL_CONFIGS):
+ list_to_test = list(
+ INPUT_LIST_WITH_APPS
+ + APP_LIST
+ + [
+ app["name"]
+ for app in device_config[CONF_APPS][CONF_ADDITIONAL_CONFIGS]
+ ]
+ )
+ else:
+ list_to_test = list(INPUT_LIST_WITH_APPS + APP_LIST)
+
+ 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:
+ assert attr["source"] == app
+ assert (
+ attr["volume_level"]
+ == float(int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2))
+ / MAX_VOLUME[VIZIO_DEVICE_CLASS_TV]
+ )
+
+
async def _test_setup_failure(hass: HomeAssistantType, config: str) -> None:
"""Test generic Vizio entity setup failure."""
with patch(
- "homeassistant.components.vizio.media_player.VizioAsync.can_connect",
+ "homeassistant.components.vizio.media_player.VizioAsync.can_connect_with_auth_check",
return_value=False,
):
config_entry = MockConfigEntry(domain=DOMAIN, data=config, unique_id=UNIQUE_ID)
@@ -133,42 +241,54 @@ async def _test_service(
async def test_speaker_on(
- hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_update: pytest.fixture
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
) -> None:
"""Test Vizio Speaker entity setup when on."""
await _test_setup(hass, DEVICE_CLASS_SPEAKER, True)
async def test_speaker_off(
- hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_update: pytest.fixture
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
) -> None:
"""Test Vizio Speaker entity setup when off."""
await _test_setup(hass, DEVICE_CLASS_SPEAKER, False)
async def test_speaker_unavailable(
- hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_update: pytest.fixture
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
) -> None:
"""Test Vizio Speaker entity setup when unavailable."""
await _test_setup(hass, DEVICE_CLASS_SPEAKER, None)
async def test_init_tv_on(
- hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_update: pytest.fixture
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
) -> None:
"""Test Vizio TV entity setup when on."""
await _test_setup(hass, DEVICE_CLASS_TV, True)
async def test_init_tv_off(
- hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_update: pytest.fixture
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
) -> None:
"""Test Vizio TV entity setup when off."""
await _test_setup(hass, DEVICE_CLASS_TV, False)
async def test_init_tv_unavailable(
- hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_update: pytest.fixture
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
) -> None:
"""Test Vizio TV entity setup when unavailable."""
await _test_setup(hass, DEVICE_CLASS_TV, None)
@@ -189,7 +309,9 @@ async def test_setup_failure_tv(
async def test_services(
- hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_update: pytest.fixture
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
) -> None:
"""Test all Vizio media player entity services."""
await _test_setup(hass, DEVICE_CLASS_TV, True)
@@ -218,7 +340,9 @@ async def test_services(
async def test_options_update(
- hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_update: pytest.fixture
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update: pytest.fixture,
) -> None:
"""Test when config entry update event fires."""
await _test_setup(hass, DEVICE_CLASS_SPEAKER, True)
@@ -295,3 +419,89 @@ async def test_update_available_to_unavailable(
) -> None:
"""Test device becomes unavailable after being available."""
await _test_update_availability_switch(hass, True, None, caplog)
+
+
+async def test_setup_with_apps(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update_with_apps: pytest.fixture,
+ caplog: pytest.fixture,
+) -> None:
+ """Test device setup with apps."""
+ await _test_setup_with_apps(hass, MOCK_USER_VALID_TV_CONFIG, CURRENT_APP)
+ await _test_service(
+ hass,
+ "launch_app",
+ SERVICE_SELECT_SOURCE,
+ {ATTR_INPUT_SOURCE: CURRENT_APP},
+ CURRENT_APP,
+ )
+
+
+async def test_setup_with_apps_include(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update_with_apps: pytest.fixture,
+ 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)
+
+
+async def test_setup_with_apps_exclude(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update_with_apps: pytest.fixture,
+ 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)
+
+
+async def test_setup_with_apps_additional_apps_config(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update_with_apps: pytest.fixture,
+ 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_service(
+ hass,
+ "launch_app",
+ SERVICE_SELECT_SOURCE,
+ {ATTR_INPUT_SOURCE: CURRENT_APP},
+ CURRENT_APP,
+ )
+ await _test_service(
+ hass,
+ "launch_app_config",
+ SERVICE_SELECT_SOURCE,
+ {ATTR_INPUT_SOURCE: CUSTOM_APP_NAME},
+ **CUSTOM_CONFIG,
+ )
+
+ # Test that invalid app does nothing
+ with patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.launch_app"
+ ) as service_call1, patch(
+ "homeassistant.components.vizio.media_player.VizioAsync.launch_app_config"
+ ) as service_call2:
+ await hass.services.async_call(
+ MP_DOMAIN,
+ SERVICE_SELECT_SOURCE,
+ service_data={ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "_"},
+ blocking=True,
+ )
+ assert not service_call1.called
+ assert not service_call2.called
+
+
+def test_invalid_apps_config(hass: HomeAssistantType):
+ """Test that schema validation fails on certain conditions."""
+ with raises(vol.Invalid):
+ vol.Schema(vol.All(VIZIO_SCHEMA, validate_apps))(MOCK_TV_APPS_FAILURE)
+
+ with raises(vol.Invalid):
+ vol.Schema(vol.All(VIZIO_SCHEMA, validate_apps))(MOCK_SPEAKER_APPS_FAILURE)
diff --git a/tests/components/weblink/__init__.py b/tests/components/weblink/__init__.py
deleted file mode 100644
index 1d58e9c24d6..00000000000
--- a/tests/components/weblink/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Tests for the weblink component."""
diff --git a/tests/components/weblink/test_init.py b/tests/components/weblink/test_init.py
deleted file mode 100644
index 5f803107c46..00000000000
--- a/tests/components/weblink/test_init.py
+++ /dev/null
@@ -1,143 +0,0 @@
-"""The tests for the weblink component."""
-import unittest
-
-from homeassistant.components import weblink
-from homeassistant.setup import setup_component
-
-from tests.common import get_test_home_assistant
-
-
-class TestComponentWeblink(unittest.TestCase):
- """Test the Weblink component."""
-
- def setUp(self):
- """Set up things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_bad_config(self):
- """Test if new entity is created."""
- assert not setup_component(
- self.hass, "weblink", {"weblink": {"entities": [{}]}}
- )
-
- def test_bad_config_relative_url(self):
- """Test if new entity is created."""
- assert not setup_component(
- self.hass,
- "weblink",
- {
- "weblink": {
- "entities": [
- {
- weblink.CONF_NAME: "My router",
- weblink.CONF_URL: "../states/group.bla",
- }
- ]
- }
- },
- )
-
- def test_bad_config_relative_file(self):
- """Test if new entity is created."""
- assert not setup_component(
- self.hass,
- "weblink",
- {
- "weblink": {
- "entities": [
- {weblink.CONF_NAME: "My group", weblink.CONF_URL: "group.bla"}
- ]
- }
- },
- )
-
- def test_good_config_absolute_path(self):
- """Test if new entity is created."""
- assert setup_component(
- self.hass,
- "weblink",
- {
- "weblink": {
- "entities": [
- {
- weblink.CONF_NAME: "My second URL",
- weblink.CONF_URL: "/states/group.bla",
- }
- ]
- }
- },
- )
-
- def test_good_config_path_short(self):
- """Test if new entity is created."""
- assert setup_component(
- self.hass,
- "weblink",
- {
- "weblink": {
- "entities": [
- {weblink.CONF_NAME: "My third URL", weblink.CONF_URL: "/states"}
- ]
- }
- },
- )
-
- def test_good_config_path_directory(self):
- """Test if new entity is created."""
- assert setup_component(
- self.hass,
- "weblink",
- {
- "weblink": {
- "entities": [
- {
- weblink.CONF_NAME: "My last URL",
- weblink.CONF_URL: "/states/bla/",
- }
- ]
- }
- },
- )
-
- def test_good_config_ftp_link(self):
- """Test if new entity is created."""
- assert setup_component(
- self.hass,
- "weblink",
- {
- "weblink": {
- "entities": [
- {
- weblink.CONF_NAME: "My FTP URL",
- weblink.CONF_URL: "ftp://somehost/",
- }
- ]
- }
- },
- )
-
- def test_entities_get_created(self):
- """Test if new entity is created."""
- assert setup_component(
- self.hass,
- weblink.DOMAIN,
- {
- weblink.DOMAIN: {
- "entities": [
- {
- weblink.CONF_NAME: "My router",
- weblink.CONF_URL: "http://127.0.0.1/",
- }
- ]
- }
- },
- )
-
- state = self.hass.states.get("weblink.my_router")
-
- assert state is not None
- assert state.state == "http://127.0.0.1/"
diff --git a/tests/components/withings/test_common.py b/tests/components/withings/test_common.py
index 4a48dcee571..65ff65ebbd8 100644
--- a/tests/components/withings/test_common.py
+++ b/tests/components/withings/test_common.py
@@ -115,7 +115,7 @@ async def test_data_manager_update_sleep_date_range(
"""Test method."""
patch_time_zone = patch(
"homeassistant.util.dt.DEFAULT_TIME_ZONE",
- new=dt.get_time_zone("America/Los_Angeles"),
+ new=dt.get_time_zone("America/Belize"),
)
with patch_time_zone:
@@ -126,10 +126,10 @@ async def test_data_manager_update_sleep_date_range(
startdate = call_args.get("startdate")
enddate = call_args.get("enddate")
- assert startdate.tzname() == "PST"
+ assert startdate.tzname() == "CST"
- assert enddate.tzname() == "PST"
- assert startdate.tzname() == "PST"
+ assert enddate.tzname() == "CST"
+ assert startdate.tzname() == "CST"
assert update_start_time < enddate
assert enddate < update_start_time + timedelta(seconds=1)
assert enddate > startdate
diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py
index 8e2d7d86e9a..29d5e4f03ef 100644
--- a/tests/components/workday/test_binary_sensor.py
+++ b/tests/components/workday/test_binary_sensor.py
@@ -55,6 +55,26 @@ class TestWorkdaySetup:
}
}
+ self.config_example1 = {
+ "binary_sensor": {
+ "platform": "workday",
+ "country": "US",
+ "workdays": ["mon", "tue", "wed", "thu", "fri"],
+ "excludes": ["sat", "sun"],
+ }
+ }
+
+ self.config_example2 = {
+ "binary_sensor": {
+ "platform": "workday",
+ "country": "DE",
+ "province": "BW",
+ "workdays": ["mon", "wed", "fri"],
+ "excludes": ["sat", "sun", "holiday"],
+ "add_holidays": ["2020-02-24"],
+ }
+ }
+
self.config_tomorrow = {
"binary_sensor": {"platform": "workday", "country": "DE", "days_offset": 1}
}
@@ -229,6 +249,43 @@ class TestWorkdaySetup:
entity = self.hass.states.get("binary_sensor.workday_sensor")
assert entity.state == "on"
+ # Freeze time to a Presidents day to test Holiday on a Work day - Jan 20th, 2020
+ # Presidents day Feb 17th 2020 is mon.
+ @patch(FUNCTION_PATH, return_value=date(2020, 2, 17))
+ def test_config_example1_holiday(self, mock_date):
+ """Test if public holidays are reported correctly."""
+ with assert_setup_component(1, "binary_sensor"):
+ setup_component(self.hass, "binary_sensor", self.config_example1)
+
+ self.hass.start()
+
+ entity = self.hass.states.get("binary_sensor.workday_sensor")
+ assert entity.state == "on"
+
+ # Freeze time to test tue - Feb 18th, 2020
+ @patch(FUNCTION_PATH, return_value=date(2020, 2, 18))
+ def test_config_example2_tue(self, mock_date):
+ """Test if public holidays are reported correctly."""
+ with assert_setup_component(1, "binary_sensor"):
+ setup_component(self.hass, "binary_sensor", self.config_example2)
+
+ self.hass.start()
+
+ entity = self.hass.states.get("binary_sensor.workday_sensor")
+ assert entity.state == "off"
+
+ # Freeze time to test mon, but added as holiday - Feb 24th, 2020
+ @patch(FUNCTION_PATH, return_value=date(2020, 2, 24))
+ def test_config_example2_add_holiday(self, mock_date):
+ """Test if public holidays are reported correctly."""
+ with assert_setup_component(1, "binary_sensor"):
+ setup_component(self.hass, "binary_sensor", self.config_example2)
+
+ self.hass.start()
+
+ entity = self.hass.states.get("binary_sensor.workday_sensor")
+ assert entity.state == "off"
+
def test_day_to_string(self):
"""Test if day_to_string is behaving correctly."""
assert binary_sensor.day_to_string(0) == "mon"
diff --git a/tests/components/wwlln/test_config_flow.py b/tests/components/wwlln/test_config_flow.py
index 8ddc25f8680..e9e32ae75e1 100644
--- a/tests/components/wwlln/test_config_flow.py
+++ b/tests/components/wwlln/test_config_flow.py
@@ -1,6 +1,4 @@
"""Define tests for the WWLLN config flow."""
-from datetime import timedelta
-
from asynctest import patch
from homeassistant import data_entry_flow
@@ -9,34 +7,34 @@ from homeassistant.components.wwlln import (
DATA_CLIENT,
DOMAIN,
async_setup_entry,
- config_flow,
-)
-from homeassistant.const import (
- CONF_LATITUDE,
- CONF_LONGITUDE,
- CONF_RADIUS,
- CONF_UNIT_SYSTEM,
)
+from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
+from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
+
+from tests.common import MockConfigEntry
async def test_duplicate_error(hass, config_entry):
"""Test that errors are shown when duplicates are added."""
conf = {CONF_LATITUDE: 39.128712, CONF_LONGITUDE: -104.9812612, CONF_RADIUS: 25}
- config_entry.add_to_hass(hass)
- flow = config_flow.WWLLNFlowHandler()
- flow.hass = hass
+ MockConfigEntry(
+ domain=DOMAIN, unique_id="39.128712, -104.9812612", data=conf
+ ).add_to_hass(hass)
- result = await flow.async_step_user(user_input=conf)
- assert result["errors"] == {"base": "identifier_exists"}
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=conf
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
async def test_show_form(hass):
"""Test that the form is served with no input."""
- flow = config_flow.WWLLNFlowHandler()
- flow.hass = hass
-
- result = await flow.async_step_user(user_input=None)
+ 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"
@@ -44,28 +42,23 @@ async def test_show_form(hass):
async def test_step_import(hass):
"""Test that the import step works."""
- # `configuration.yaml` will always return a timedelta for the `window`
- # parameter, FYI:
conf = {
CONF_LATITUDE: 39.128712,
CONF_LONGITUDE: -104.9812612,
CONF_RADIUS: 25,
- CONF_UNIT_SYSTEM: "metric",
- CONF_WINDOW: timedelta(minutes=10),
}
- flow = config_flow.WWLLNFlowHandler()
- flow.hass = hass
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
+ )
- result = await flow.async_step_import(import_config=conf)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "39.128712, -104.9812612"
assert result["data"] == {
CONF_LATITUDE: 39.128712,
CONF_LONGITUDE: -104.9812612,
CONF_RADIUS: 25,
- CONF_UNIT_SYSTEM: "metric",
- CONF_WINDOW: 600.0,
+ CONF_WINDOW: 3600.0,
}
@@ -73,17 +66,38 @@ async def test_step_user(hass):
"""Test that the user step works."""
conf = {CONF_LATITUDE: 39.128712, CONF_LONGITUDE: -104.9812612, CONF_RADIUS: 25}
- flow = config_flow.WWLLNFlowHandler()
- flow.hass = hass
+ 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"] == "39.128712, -104.9812612"
+ assert result["data"] == {
+ CONF_LATITUDE: 39.128712,
+ CONF_LONGITUDE: -104.9812612,
+ CONF_RADIUS: 25,
+ CONF_WINDOW: 3600.0,
+ }
+
+
+async def test_different_unit_system(hass):
+ """Test that the config flow picks up the HASS unit system."""
+ conf = {
+ CONF_LATITUDE: 39.128712,
+ CONF_LONGITUDE: -104.9812612,
+ CONF_RADIUS: 25,
+ }
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=conf
+ )
- result = await flow.async_step_user(user_input=conf)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "39.128712, -104.9812612"
assert result["data"] == {
CONF_LATITUDE: 39.128712,
CONF_LONGITUDE: -104.9812612,
CONF_RADIUS: 25,
- CONF_UNIT_SYSTEM: "metric",
CONF_WINDOW: 3600.0,
}
@@ -94,20 +108,19 @@ async def test_custom_window(hass):
CONF_LATITUDE: 39.128712,
CONF_LONGITUDE: -104.9812612,
CONF_RADIUS: 25,
- CONF_WINDOW: timedelta(hours=2),
+ CONF_WINDOW: 7200,
}
- flow = config_flow.WWLLNFlowHandler()
- flow.hass = hass
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=conf
+ )
- result = await flow.async_step_user(user_input=conf)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "39.128712, -104.9812612"
assert result["data"] == {
CONF_LATITUDE: 39.128712,
CONF_LONGITUDE: -104.9812612,
CONF_RADIUS: 25,
- CONF_UNIT_SYSTEM: "metric",
CONF_WINDOW: 7200,
}
diff --git a/tests/components/yandex_transport/test_yandex_transport_sensor.py b/tests/components/yandex_transport/test_yandex_transport_sensor.py
index a67108dc93b..f0664e4f045 100644
--- a/tests/components/yandex_transport/test_yandex_transport_sensor.py
+++ b/tests/components/yandex_transport/test_yandex_transport_sensor.py
@@ -40,14 +40,14 @@ TEST_CONFIG = {
}
FILTERED_ATTRS = {
- "т36": ["16:10", "16:17", "16:26"],
- "т47": ["16:09", "16:10"],
- "м10": ["16:12", "16:20"],
+ "т36": ["18:25", "18:42", "18:46"],
+ "т47": ["18:35", "18:37", "18:40", "18:42"],
+ "м10": ["18:20", "18:27", "18:29", "18:41", "18:43"],
"stop_name": "7-й автобусный парк",
"attribution": "Data provided by maps.yandex.ru",
}
-RESULT_STATE = dt_util.utc_from_timestamp(1570972183).isoformat(timespec="seconds")
+RESULT_STATE = dt_util.utc_from_timestamp(1583421540).isoformat(timespec="seconds")
async def assert_setup_sensor(hass, config, count=1):
diff --git a/tests/components/yr/test_sensor.py b/tests/components/yr/test_sensor.py
index 161a7cef66b..398acd7d554 100644
--- a/tests/components/yr/test_sensor.py
+++ b/tests/components/yr/test_sensor.py
@@ -3,6 +3,7 @@ from datetime import datetime
from unittest.mock import patch
from homeassistant.bootstrap import async_setup_component
+from homeassistant.const import SPEED_METERS_PER_SECOND, UNIT_PERCENTAGE
import homeassistant.util.dt as dt_util
from tests.common import assert_setup_component, load_fixture
@@ -62,15 +63,15 @@ async def test_custom_setup(hass, aioclient_mock):
assert state.state == "103.6"
state = hass.states.get("sensor.yr_humidity")
- assert state.attributes.get("unit_of_measurement") == "%"
+ assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
assert state.state == "55.5"
state = hass.states.get("sensor.yr_fog")
- assert state.attributes.get("unit_of_measurement") == "%"
+ assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
assert state.state == "0.0"
state = hass.states.get("sensor.yr_wind_speed")
- assert state.attributes.get("unit_of_measurement") == "m/s"
+ assert state.attributes.get("unit_of_measurement") == SPEED_METERS_PER_SECOND
assert state.state == "3.5"
@@ -108,13 +109,13 @@ async def test_forecast_setup(hass, aioclient_mock):
assert state.state == "148.9"
state = hass.states.get("sensor.yr_humidity")
- assert state.attributes.get("unit_of_measurement") == "%"
+ assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
assert state.state == "77.4"
state = hass.states.get("sensor.yr_fog")
- assert state.attributes.get("unit_of_measurement") == "%"
+ assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
assert state.state == "0.0"
state = hass.states.get("sensor.yr_wind_speed")
- assert state.attributes.get("unit_of_measurement") == "m/s"
+ assert state.attributes.get("unit_of_measurement") == SPEED_METERS_PER_SECOND
assert state.state == "3.6"
diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py
index 03b6ed21148..3753136d59d 100644
--- a/tests/components/zha/common.py
+++ b/tests/components/zha/common.py
@@ -29,6 +29,7 @@ class FakeEndpoint:
self.model = model
self.profile_id = zigpy.profiles.zha.PROFILE_ID
self.device_type = None
+ self.request = CoroutineMock()
def add_input_cluster(self, cluster_id):
"""Add an input cluster."""
@@ -51,7 +52,7 @@ def patch_cluster(cluster):
cluster.configure_reporting = CoroutineMock(return_value=[0])
cluster.deserialize = Mock()
cluster.handle_cluster_request = Mock()
- cluster.read_attributes = CoroutineMock()
+ cluster.read_attributes = CoroutineMock(return_value=[{}, {}])
cluster.read_attributes_raw = Mock()
cluster.unbind = CoroutineMock(return_value=[0])
cluster.write_attributes = CoroutineMock(return_value=[0])
@@ -63,6 +64,7 @@ class FakeDevice:
def __init__(self, app, ieee, manufacturer, model, node_desc=None):
"""Init fake device."""
self._application = app
+ self.application = app
self.ieee = zigpy.types.EUI64.convert(ieee)
self.nwk = 0xB79C
self.zdo = Mock()
@@ -100,6 +102,23 @@ def make_attribute(attrid, value, status=0):
return attr
+def send_attribute_report(hass, cluster, attrid, value):
+ """Send a single attribute report."""
+ return send_attributes_report(hass, cluster, {attrid: value})
+
+
+async def send_attributes_report(hass, cluster: int, attributes: dict):
+ """Cause the sensor to receive an attribute report from the network.
+
+ This is to simulate the normal device communication that happens when a
+ device is paired to the zigbee network.
+ """
+ attrs = [make_attribute(attrid, value) for attrid, value in attributes.items()]
+ hdr = make_zcl_header(zcl_f.Command.Report_Attributes)
+ cluster.handle_message(hdr, [attrs])
+ await hass.async_block_till_done()
+
+
async def find_entity_id(domain, zha_device, hass):
"""Find the entity id under the testing.
@@ -125,13 +144,15 @@ async def async_enable_traffic(hass, zha_devices):
await hass.async_block_till_done()
-def make_zcl_header(command_id: int, global_command: bool = True) -> zcl_f.ZCLHeader:
+def make_zcl_header(
+ command_id: int, global_command: bool = True, tsn: int = 1
+) -> zcl_f.ZCLHeader:
"""Cluster.handle_message() ZCL Header helper."""
if global_command:
frc = zcl_f.FrameControl(zcl_f.FrameType.GLOBAL_COMMAND)
else:
frc = zcl_f.FrameControl(zcl_f.FrameType.CLUSTER_COMMAND)
- return zcl_f.ZCLHeader(frc, tsn=1, command_id=command_id)
+ return zcl_f.ZCLHeader(frc, tsn=tsn, command_id=command_id)
def reset_clusters(clusters):
diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py
index 26dd2b5da5c..e6056428db6 100644
--- a/tests/components/zha/conftest.py
+++ b/tests/components/zha/conftest.py
@@ -9,6 +9,7 @@ import zigpy.group
import zigpy.types
import homeassistant.components.zha.core.const as zha_const
+import homeassistant.components.zha.core.device as zha_core_device
import homeassistant.components.zha.core.registries as zha_regs
from homeassistant.setup import async_setup_component
@@ -63,7 +64,7 @@ async def config_entry_fixture(hass):
@pytest.fixture
def setup_zha(hass, config_entry, zigpy_app_controller, zigpy_radio):
"""Set up ZHA component."""
- zha_config = {zha_const.DOMAIN: {zha_const.CONF_ENABLE_QUIRKS: False}}
+ zha_config = {zha_const.CONF_ENABLE_QUIRKS: False}
radio_details = {
zha_const.ZHA_GW_RADIO: mock.MagicMock(return_value=zigpy_radio),
@@ -71,9 +72,12 @@ def setup_zha(hass, config_entry, zigpy_app_controller, zigpy_radio):
zha_const.ZHA_GW_RADIO_DESCRIPTION: "mock radio",
}
- async def _setup():
+ async def _setup(config=None):
+ config = config or {}
with mock.patch.dict(zha_regs.RADIO_TYPES, {"MockRadio": radio_details}):
- status = await async_setup_component(hass, zha_const.DOMAIN, zha_config)
+ status = await async_setup_component(
+ hass, zha_const.DOMAIN, {zha_const.DOMAIN: {**zha_config, **config}}
+ )
assert status is True
await hass.async_block_till_done()
@@ -161,4 +165,39 @@ def zha_device_restored(hass, zigpy_app_controller, setup_zha):
@pytest.fixture(params=["zha_device_joined", "zha_device_restored"])
def zha_device_joined_restored(request):
"""Join or restore ZHA device."""
- return request.getfixturevalue(request.param)
+ named_method = request.getfixturevalue(request.param)
+ named_method.name = request.param
+ return named_method
+
+
+@pytest.fixture
+def zha_device_mock(hass, zigpy_device_mock):
+ """Return a zha Device factory."""
+
+ def _zha_device(
+ endpoints=None,
+ ieee="00:11:22:33:44:55:66:77",
+ manufacturer="mock manufacturer",
+ model="mock model",
+ node_desc=b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00",
+ ):
+ if endpoints is None:
+ endpoints = {
+ 1: {
+ "in_clusters": [0, 1, 8, 768],
+ "out_clusters": [0x19],
+ "device_type": 0x0105,
+ },
+ 2: {
+ "in_clusters": [0],
+ "out_clusters": [6, 8, 0x19, 768],
+ "device_type": 0x0810,
+ },
+ }
+ zigpy_device = zigpy_device_mock(
+ endpoints, ieee, manufacturer, model, node_desc
+ )
+ zha_device = zha_core_device.ZHADevice(hass, zigpy_device, mock.MagicMock())
+ return zha_device
+
+ return _zha_device
diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py
index a22bfa54dae..730c7c844f2 100644
--- a/tests/components/zha/test_binary_sensor.py
+++ b/tests/components/zha/test_binary_sensor.py
@@ -2,7 +2,6 @@
import pytest
import zigpy.zcl.clusters.measurement as measurement
import zigpy.zcl.clusters.security as security
-import zigpy.zcl.foundation as zcl_f
from homeassistant.components.binary_sensor import DOMAIN
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE
@@ -11,8 +10,7 @@ from .common import (
async_enable_traffic,
async_test_rejoin,
find_entity_id,
- make_attribute,
- make_zcl_header,
+ send_attributes_report,
)
DEVICE_IAS = {
@@ -36,17 +34,11 @@ DEVICE_OCCUPANCY = {
async def async_test_binary_sensor_on_off(hass, cluster, entity_id):
"""Test getting on and off messages for binary sensors."""
# binary sensor on
- attr = make_attribute(0, 1)
- hdr = make_zcl_header(zcl_f.Command.Report_Attributes)
-
- cluster.handle_message(hdr, [[attr]])
- await hass.async_block_till_done()
+ await send_attributes_report(hass, cluster, {1: 0, 0: 1, 2: 2})
assert hass.states.get(entity_id).state == STATE_ON
# binary sensor off
- attr.value.value = 0
- cluster.handle_message(hdr, [[attr]])
- await hass.async_block_till_done()
+ await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 2})
assert hass.states.get(entity_id).state == STATE_OFF
diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py
index ee493ca01a7..ec9c172430c 100644
--- a/tests/components/zha/test_channels.py
+++ b/tests/components/zha/test_channels.py
@@ -1,12 +1,18 @@
"""Test ZHA Core channels."""
+import asyncio
+from unittest import mock
+
+import asynctest
import pytest
import zigpy.types as t
+import zigpy.zcl.clusters
-import homeassistant.components.zha.core.channels as channels
-import homeassistant.components.zha.core.device as zha_device
+import homeassistant.components.zha.core.channels as zha_channels
+import homeassistant.components.zha.core.channels.base as base_channels
+import homeassistant.components.zha.core.const as zha_const
import homeassistant.components.zha.core.registries as registries
-from .common import get_zha_gateway
+from .common import get_zha_gateway, make_zcl_header
@pytest.fixture
@@ -28,6 +34,46 @@ async def zha_gateway(hass, setup_zha):
return get_zha_gateway(hass)
+@pytest.fixture
+def channel_pool():
+ """Endpoint Channels fixture."""
+ ch_pool_mock = mock.MagicMock(spec_set=zha_channels.ChannelPool)
+ type(ch_pool_mock).skip_configuration = mock.PropertyMock(return_value=False)
+ ch_pool_mock.id = 1
+ return ch_pool_mock
+
+
+@pytest.fixture
+def poll_control_ch(channel_pool, zigpy_device_mock):
+ """Poll control channel fixture."""
+ cluster_id = zigpy.zcl.clusters.general.PollControl.cluster_id
+ zigpy_dev = zigpy_device_mock(
+ {1: {"in_clusters": [cluster_id], "out_clusters": [], "device_type": 0x1234}},
+ "00:11:22:33:44:55:66:77",
+ "test manufacturer",
+ "test model",
+ )
+
+ cluster = zigpy_dev.endpoints[1].in_clusters[cluster_id]
+ channel_class = registries.ZIGBEE_CHANNEL_REGISTRY.get(cluster_id)
+ return channel_class(cluster, channel_pool)
+
+
+@pytest.fixture
+async def poll_control_device(zha_device_restored, zigpy_device_mock):
+ """Poll control device fixture."""
+ cluster_id = zigpy.zcl.clusters.general.PollControl.cluster_id
+ zigpy_dev = zigpy_device_mock(
+ {1: {"in_clusters": [cluster_id], "out_clusters": [], "device_type": 0x1234}},
+ "00:11:22:33:44:55:66:77",
+ "test manufacturer",
+ "test model",
+ )
+
+ zha_device = await zha_device_restored(zigpy_dev)
+ return zha_device
+
+
@pytest.mark.parametrize(
"cluster_id, bind_count, attrs",
[
@@ -72,7 +118,7 @@ async def zha_gateway(hass, setup_zha):
],
)
async def test_in_channel_config(
- cluster_id, bind_count, attrs, hass, zigpy_device_mock, zha_gateway
+ cluster_id, bind_count, attrs, channel_pool, zigpy_device_mock, zha_gateway
):
"""Test ZHA core channel configuration for input clusters."""
zigpy_dev = zigpy_device_mock(
@@ -81,13 +127,12 @@ async def test_in_channel_config(
"test manufacturer",
"test model",
)
- zha_dev = zha_device.ZHADevice(hass, zigpy_dev, zha_gateway)
cluster = zigpy_dev.endpoints[1].in_clusters[cluster_id]
channel_class = registries.ZIGBEE_CHANNEL_REGISTRY.get(
- cluster_id, channels.AttributeListeningChannel
+ cluster_id, base_channels.ZigbeeChannel
)
- channel = channel_class(cluster, zha_dev)
+ channel = channel_class(cluster, channel_pool)
await channel.async_configure()
@@ -130,7 +175,7 @@ async def test_in_channel_config(
],
)
async def test_out_channel_config(
- cluster_id, bind_count, zha_gateway, hass, zigpy_device_mock
+ cluster_id, bind_count, channel_pool, zigpy_device_mock, zha_gateway
):
"""Test ZHA core channel configuration for output clusters."""
zigpy_dev = zigpy_device_mock(
@@ -139,14 +184,13 @@ async def test_out_channel_config(
"test manufacturer",
"test model",
)
- zha_dev = zha_device.ZHADevice(hass, zigpy_dev, zha_gateway)
cluster = zigpy_dev.endpoints[1].out_clusters[cluster_id]
cluster.bind_only = True
channel_class = registries.ZIGBEE_CHANNEL_REGISTRY.get(
- cluster_id, channels.AttributeListeningChannel
+ cluster_id, base_channels.ZigbeeChannel
)
- channel = channel_class(cluster, zha_dev)
+ channel = channel_class(cluster, channel_pool)
await channel.async_configure()
@@ -159,4 +203,265 @@ def test_channel_registry():
for (cluster_id, channel) in registries.ZIGBEE_CHANNEL_REGISTRY.items():
assert isinstance(cluster_id, int)
assert 0 <= cluster_id <= 0xFFFF
- assert issubclass(channel, channels.ZigbeeChannel)
+ assert issubclass(channel, base_channels.ZigbeeChannel)
+
+
+def test_epch_unclaimed_channels(channel):
+ """Test unclaimed channels."""
+
+ ch_1 = channel(zha_const.CHANNEL_ON_OFF, 6)
+ ch_2 = channel(zha_const.CHANNEL_LEVEL, 8)
+ ch_3 = channel(zha_const.CHANNEL_COLOR, 768)
+
+ ep_channels = zha_channels.ChannelPool(
+ mock.MagicMock(spec_set=zha_channels.Channels), mock.sentinel.ep
+ )
+ all_channels = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3}
+ with mock.patch.dict(ep_channels.all_channels, all_channels, clear=True):
+ available = ep_channels.unclaimed_channels()
+ assert ch_1 in available
+ assert ch_2 in available
+ assert ch_3 in available
+
+ ep_channels.claimed_channels[ch_2.id] = ch_2
+ available = ep_channels.unclaimed_channels()
+ assert ch_1 in available
+ assert ch_2 not in available
+ assert ch_3 in available
+
+ ep_channels.claimed_channels[ch_1.id] = ch_1
+ available = ep_channels.unclaimed_channels()
+ assert ch_1 not in available
+ assert ch_2 not in available
+ assert ch_3 in available
+
+ ep_channels.claimed_channels[ch_3.id] = ch_3
+ available = ep_channels.unclaimed_channels()
+ assert ch_1 not in available
+ assert ch_2 not in available
+ assert ch_3 not in available
+
+
+def test_epch_claim_channels(channel):
+ """Test channel claiming."""
+
+ ch_1 = channel(zha_const.CHANNEL_ON_OFF, 6)
+ ch_2 = channel(zha_const.CHANNEL_LEVEL, 8)
+ ch_3 = channel(zha_const.CHANNEL_COLOR, 768)
+
+ ep_channels = zha_channels.ChannelPool(
+ mock.MagicMock(spec_set=zha_channels.Channels), mock.sentinel.ep
+ )
+ all_channels = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3}
+ with mock.patch.dict(ep_channels.all_channels, all_channels, clear=True):
+ assert ch_1.id not in ep_channels.claimed_channels
+ assert ch_2.id not in ep_channels.claimed_channels
+ assert ch_3.id not in ep_channels.claimed_channels
+
+ ep_channels.claim_channels([ch_2])
+ assert ch_1.id not in ep_channels.claimed_channels
+ assert ch_2.id in ep_channels.claimed_channels
+ assert ep_channels.claimed_channels[ch_2.id] is ch_2
+ assert ch_3.id not in ep_channels.claimed_channels
+
+ ep_channels.claim_channels([ch_3, ch_1])
+ assert ch_1.id in ep_channels.claimed_channels
+ assert ep_channels.claimed_channels[ch_1.id] is ch_1
+ assert ch_2.id in ep_channels.claimed_channels
+ assert ep_channels.claimed_channels[ch_2.id] is ch_2
+ assert ch_3.id in ep_channels.claimed_channels
+ assert ep_channels.claimed_channels[ch_3.id] is ch_3
+ assert "1:0x0300" in ep_channels.claimed_channels
+
+
+@mock.patch("homeassistant.components.zha.core.channels.ChannelPool.add_relay_channels")
+@mock.patch(
+ "homeassistant.components.zha.core.discovery.PROBE.discover_entities",
+ mock.MagicMock(),
+)
+def test_ep_channels_all_channels(m1, zha_device_mock):
+ """Test EndpointChannels adding all channels."""
+ zha_device = zha_device_mock(
+ {
+ 1: {"in_clusters": [0, 1, 6, 8], "out_clusters": [], "device_type": 0x0000},
+ 2: {
+ "in_clusters": [0, 1, 6, 8, 768],
+ "out_clusters": [],
+ "device_type": 0x0000,
+ },
+ }
+ )
+ channels = zha_channels.Channels(zha_device)
+
+ ep_channels = zha_channels.ChannelPool.new(channels, 1)
+ assert "1:0x0000" in ep_channels.all_channels
+ assert "1:0x0001" in ep_channels.all_channels
+ assert "1:0x0006" in ep_channels.all_channels
+ assert "1:0x0008" in ep_channels.all_channels
+ assert "1:0x0300" not in ep_channels.all_channels
+ assert "2:0x0000" not in ep_channels.all_channels
+ assert "2:0x0001" not in ep_channels.all_channels
+ assert "2:0x0006" not in ep_channels.all_channels
+ assert "2:0x0008" not in ep_channels.all_channels
+ assert "2:0x0300" not in ep_channels.all_channels
+
+ channels = zha_channels.Channels(zha_device)
+ ep_channels = zha_channels.ChannelPool.new(channels, 2)
+ assert "1:0x0000" not in ep_channels.all_channels
+ assert "1:0x0001" not in ep_channels.all_channels
+ assert "1:0x0006" not in ep_channels.all_channels
+ assert "1:0x0008" not in ep_channels.all_channels
+ assert "1:0x0300" not in ep_channels.all_channels
+ assert "2:0x0000" in ep_channels.all_channels
+ assert "2:0x0001" in ep_channels.all_channels
+ assert "2:0x0006" in ep_channels.all_channels
+ assert "2:0x0008" in ep_channels.all_channels
+ assert "2:0x0300" in ep_channels.all_channels
+
+
+@mock.patch("homeassistant.components.zha.core.channels.ChannelPool.add_relay_channels")
+@mock.patch(
+ "homeassistant.components.zha.core.discovery.PROBE.discover_entities",
+ mock.MagicMock(),
+)
+def test_channel_power_config(m1, zha_device_mock):
+ """Test that channels only get a single power channel."""
+ in_clusters = [0, 1, 6, 8]
+ zha_device = zha_device_mock(
+ {
+ 1: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0x0000},
+ 2: {
+ "in_clusters": [*in_clusters, 768],
+ "out_clusters": [],
+ "device_type": 0x0000,
+ },
+ }
+ )
+ channels = zha_channels.Channels.new(zha_device)
+ pools = {pool.id: pool for pool in channels.pools}
+ assert "1:0x0000" in pools[1].all_channels
+ assert "1:0x0001" in pools[1].all_channels
+ assert "1:0x0006" in pools[1].all_channels
+ assert "1:0x0008" in pools[1].all_channels
+ assert "1:0x0300" not in pools[1].all_channels
+ assert "2:0x0000" in pools[2].all_channels
+ assert "2:0x0001" not in pools[2].all_channels
+ assert "2:0x0006" in pools[2].all_channels
+ assert "2:0x0008" in pools[2].all_channels
+ assert "2:0x0300" in pools[2].all_channels
+
+ zha_device = zha_device_mock(
+ {
+ 1: {"in_clusters": [], "out_clusters": [], "device_type": 0x0000},
+ 2: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0x0000},
+ }
+ )
+ channels = zha_channels.Channels.new(zha_device)
+ pools = {pool.id: pool for pool in channels.pools}
+ assert "1:0x0001" not in pools[1].all_channels
+ assert "2:0x0001" in pools[2].all_channels
+
+ zha_device = zha_device_mock(
+ {2: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0x0000}}
+ )
+ channels = zha_channels.Channels.new(zha_device)
+ pools = {pool.id: pool for pool in channels.pools}
+ assert "2:0x0001" in pools[2].all_channels
+
+
+async def test_ep_channels_configure(channel):
+ """Test unclaimed channels."""
+
+ ch_1 = channel(zha_const.CHANNEL_ON_OFF, 6)
+ ch_2 = channel(zha_const.CHANNEL_LEVEL, 8)
+ ch_3 = channel(zha_const.CHANNEL_COLOR, 768)
+ ch_3.async_configure = asynctest.CoroutineMock(side_effect=asyncio.TimeoutError)
+ ch_3.async_initialize = asynctest.CoroutineMock(side_effect=asyncio.TimeoutError)
+ ch_4 = channel(zha_const.CHANNEL_ON_OFF, 6)
+ ch_5 = channel(zha_const.CHANNEL_LEVEL, 8)
+ ch_5.async_configure = asynctest.CoroutineMock(side_effect=asyncio.TimeoutError)
+ ch_5.async_initialize = asynctest.CoroutineMock(side_effect=asyncio.TimeoutError)
+
+ channels = mock.MagicMock(spec_set=zha_channels.Channels)
+ type(channels).semaphore = mock.PropertyMock(return_value=asyncio.Semaphore(3))
+ ep_channels = zha_channels.ChannelPool(channels, mock.sentinel.ep)
+
+ claimed = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3}
+ relay = {ch_4.id: ch_4, ch_5.id: ch_5}
+
+ with mock.patch.dict(ep_channels.claimed_channels, claimed, clear=True):
+ with mock.patch.dict(ep_channels.relay_channels, relay, clear=True):
+ await ep_channels.async_configure()
+ await ep_channels.async_initialize(mock.sentinel.from_cache)
+
+ for ch in [*claimed.values(), *relay.values()]:
+ assert ch.async_initialize.call_count == 1
+ assert ch.async_initialize.await_count == 1
+ assert ch.async_initialize.call_args[0][0] is mock.sentinel.from_cache
+ assert ch.async_configure.call_count == 1
+ assert ch.async_configure.await_count == 1
+
+ assert ch_3.warning.call_count == 2
+ assert ch_5.warning.call_count == 2
+
+
+async def test_poll_control_configure(poll_control_ch):
+ """Test poll control channel configuration."""
+ await poll_control_ch.async_configure()
+ assert poll_control_ch.cluster.write_attributes.call_count == 1
+ assert poll_control_ch.cluster.write_attributes.call_args[0][0] == {
+ "checkin_interval": poll_control_ch.CHECKIN_INTERVAL
+ }
+
+
+async def test_poll_control_checkin_response(poll_control_ch):
+ """Test poll control channel checkin response."""
+ rsp_mock = asynctest.CoroutineMock()
+ set_interval_mock = asynctest.CoroutineMock()
+ cluster = poll_control_ch.cluster
+ patch_1 = mock.patch.object(cluster, "checkin_response", rsp_mock)
+ patch_2 = mock.patch.object(cluster, "set_long_poll_interval", set_interval_mock)
+
+ with patch_1, patch_2:
+ await poll_control_ch.check_in_response(33)
+
+ assert rsp_mock.call_count == 1
+ assert set_interval_mock.call_count == 1
+
+ await poll_control_ch.check_in_response(33)
+ assert cluster.endpoint.request.call_count == 2
+ assert cluster.endpoint.request.await_count == 2
+ assert cluster.endpoint.request.call_args_list[0][0][1] == 33
+ assert cluster.endpoint.request.call_args_list[0][0][0] == 0x0020
+ assert cluster.endpoint.request.call_args_list[1][0][0] == 0x0020
+
+
+async def test_poll_control_cluster_command(hass, poll_control_device):
+ """Test poll control channel response to cluster command."""
+ checkin_mock = asynctest.CoroutineMock()
+ poll_control_ch = poll_control_device.channels.pools[0].all_channels["1:0x0020"]
+ cluster = poll_control_ch.cluster
+
+ events = []
+ hass.bus.async_listen("zha_event", lambda x: events.append(x))
+ await hass.async_block_till_done()
+
+ with mock.patch.object(poll_control_ch, "check_in_response", checkin_mock):
+ tsn = 22
+ hdr = make_zcl_header(0, global_command=False, tsn=tsn)
+ assert not events
+ cluster.handle_message(
+ hdr, [mock.sentinel.args, mock.sentinel.args2, mock.sentinel.args3]
+ )
+ await hass.async_block_till_done()
+
+ assert checkin_mock.call_count == 1
+ assert checkin_mock.await_count == 1
+ assert checkin_mock.await_args[0][0] == tsn
+ assert len(events) == 1
+ data = events[0].data
+ assert data["command"] == "checkin"
+ assert data["args"][0] is mock.sentinel.args
+ assert data["args"][1] is mock.sentinel.args2
+ assert data["args"][2] is mock.sentinel.args3
+ assert data["unique_id"] == "00:11:22:33:44:55:66:77:1:0x0020"
diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py
index e5883605e34..188ddf69a23 100644
--- a/tests/components/zha/test_cover.py
+++ b/tests/components/zha/test_cover.py
@@ -14,8 +14,7 @@ from .common import (
async_enable_traffic,
async_test_rejoin,
find_entity_id,
- make_attribute,
- make_zcl_header,
+ send_attributes_report,
)
from tests.common import mock_coro
@@ -45,7 +44,7 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device):
return 100
with patch(
- "homeassistant.components.zha.core.channels.ZigbeeChannel.get_attribute_value",
+ "homeassistant.components.zha.core.channels.base.ZigbeeChannel.get_attribute_value",
new=MagicMock(side_effect=get_chan_attr),
) as get_attr_mock:
# load up cover domain
@@ -64,19 +63,12 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device):
await async_enable_traffic(hass, [zha_device])
await hass.async_block_till_done()
- attr = make_attribute(8, 100)
- hdr = make_zcl_header(zcl_f.Command.Report_Attributes)
- cluster.handle_message(hdr, [[attr]])
- await hass.async_block_till_done()
-
# test that the state has changed from unavailable to off
+ await send_attributes_report(hass, cluster, {0: 0, 8: 100, 1: 1})
assert hass.states.get(entity_id).state == STATE_CLOSED
# test to see if it opens
- attr = make_attribute(8, 0)
- hdr = make_zcl_header(zcl_f.Command.Report_Attributes)
- cluster.handle_message(hdr, [[attr]])
- await hass.async_block_till_done()
+ await send_attributes_report(hass, cluster, {0: 1, 8: 0, 1: 100})
assert hass.states.get(entity_id).state == STATE_OPEN
# close from UI
@@ -88,7 +80,7 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device):
)
assert cluster.request.call_count == 1
assert cluster.request.call_args == call(
- False, 0x1, (), expect_reply=True, manufacturer=None
+ False, 0x1, (), expect_reply=True, manufacturer=None, tsn=None
)
# open from UI
@@ -100,7 +92,7 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device):
)
assert cluster.request.call_count == 1
assert cluster.request.call_args == call(
- False, 0x0, (), expect_reply=True, manufacturer=None
+ False, 0x0, (), expect_reply=True, manufacturer=None, tsn=None
)
# set position UI
@@ -115,7 +107,13 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device):
)
assert cluster.request.call_count == 1
assert cluster.request.call_args == call(
- False, 0x5, (zigpy.types.uint8_t,), 53, expect_reply=True, manufacturer=None
+ False,
+ 0x5,
+ (zigpy.types.uint8_t,),
+ 53,
+ expect_reply=True,
+ manufacturer=None,
+ tsn=None,
)
# stop from UI
@@ -127,7 +125,7 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device):
)
assert cluster.request.call_count == 1
assert cluster.request.call_args == call(
- False, 0x2, (), expect_reply=True, manufacturer=None
+ False, 0x2, (), expect_reply=True, manufacturer=None, tsn=None
)
# test rejoin
diff --git a/tests/components/zha/test_device.py b/tests/components/zha/test_device.py
new file mode 100644
index 00000000000..edfab1d11d1
--- /dev/null
+++ b/tests/components/zha/test_device.py
@@ -0,0 +1,192 @@
+"""Test zha device switch."""
+from datetime import timedelta
+import time
+from unittest import mock
+
+import asynctest
+import pytest
+import zigpy.zcl.clusters.general as general
+
+import homeassistant.components.zha.core.device as zha_core_device
+import homeassistant.util.dt as dt_util
+
+from .common import async_enable_traffic
+
+from tests.common import async_fire_time_changed
+
+
+@pytest.fixture
+def zigpy_device(zigpy_device_mock):
+ """Device tracker zigpy device."""
+
+ def _dev(with_basic_channel: bool = True):
+ in_clusters = [general.OnOff.cluster_id]
+ if with_basic_channel:
+ in_clusters.append(general.Basic.cluster_id)
+
+ endpoints = {
+ 3: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0}
+ }
+ return zigpy_device_mock(endpoints)
+
+ return _dev
+
+
+@pytest.fixture
+def zigpy_device_mains(zigpy_device_mock):
+ """Device tracker zigpy device."""
+
+ def _dev(with_basic_channel: bool = True):
+ in_clusters = [general.OnOff.cluster_id]
+ if with_basic_channel:
+ in_clusters.append(general.Basic.cluster_id)
+
+ endpoints = {
+ 3: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0}
+ }
+ return zigpy_device_mock(
+ endpoints, node_descriptor=b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00"
+ )
+
+ return _dev
+
+
+@pytest.fixture
+def device_with_basic_channel(zigpy_device_mains):
+ """Return a zha device with a basic channel present."""
+ return zigpy_device_mains(with_basic_channel=True)
+
+
+@pytest.fixture
+def device_without_basic_channel(zigpy_device):
+ """Return a zha device with a basic channel present."""
+ return zigpy_device(with_basic_channel=False)
+
+
+def _send_time_changed(hass, seconds):
+ """Send a time changed event."""
+ now = dt_util.utcnow() + timedelta(seconds=seconds)
+ async_fire_time_changed(hass, now)
+
+
+@asynctest.patch(
+ "homeassistant.components.zha.core.channels.general.BasicChannel.async_initialize",
+ new=mock.MagicMock(),
+)
+async def test_check_available_success(
+ hass, device_with_basic_channel, zha_device_restored
+):
+ """Check device availability success on 1st try."""
+
+ # pylint: disable=protected-access
+ zha_device = await zha_device_restored(device_with_basic_channel)
+ await async_enable_traffic(hass, [zha_device])
+ basic_ch = device_with_basic_channel.endpoints[3].basic
+
+ basic_ch.read_attributes.reset_mock()
+ device_with_basic_channel.last_seen = None
+ assert zha_device.available is True
+ _send_time_changed(hass, zha_core_device._CONSIDER_UNAVAILABLE_MAINS + 2)
+ await hass.async_block_till_done()
+ assert zha_device.available is False
+ assert basic_ch.read_attributes.await_count == 0
+
+ device_with_basic_channel.last_seen = (
+ time.time() - zha_core_device._CONSIDER_UNAVAILABLE_MAINS - 2
+ )
+ _seens = [time.time(), device_with_basic_channel.last_seen]
+
+ def _update_last_seen(*args, **kwargs):
+ device_with_basic_channel.last_seen = _seens.pop()
+
+ basic_ch.read_attributes.side_effect = _update_last_seen
+
+ # successfully ping zigpy device, but zha_device is not yet available
+ _send_time_changed(hass, 91)
+ await hass.async_block_till_done()
+ assert basic_ch.read_attributes.await_count == 1
+ assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"]
+ assert zha_device.available is False
+
+ # There was traffic from the device: pings, but not yet available
+ _send_time_changed(hass, 91)
+ await hass.async_block_till_done()
+ assert basic_ch.read_attributes.await_count == 2
+ assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"]
+ assert zha_device.available is False
+
+ # There was traffic from the device: don't try to ping, marked as available
+ _send_time_changed(hass, 91)
+ await hass.async_block_till_done()
+ assert basic_ch.read_attributes.await_count == 2
+ assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"]
+ assert zha_device.available is True
+
+
+@asynctest.patch(
+ "homeassistant.components.zha.core.channels.general.BasicChannel.async_initialize",
+ new=mock.MagicMock(),
+)
+async def test_check_available_unsuccessful(
+ hass, device_with_basic_channel, zha_device_restored
+):
+ """Check device availability all tries fail."""
+
+ # pylint: disable=protected-access
+ zha_device = await zha_device_restored(device_with_basic_channel)
+ await async_enable_traffic(hass, [zha_device])
+ basic_ch = device_with_basic_channel.endpoints[3].basic
+
+ assert zha_device.available is True
+ assert basic_ch.read_attributes.await_count == 0
+
+ device_with_basic_channel.last_seen = (
+ time.time() - zha_core_device._CONSIDER_UNAVAILABLE_MAINS - 2
+ )
+
+ # unsuccessfuly ping zigpy device, but zha_device is still available
+ _send_time_changed(hass, 91)
+ await hass.async_block_till_done()
+ assert basic_ch.read_attributes.await_count == 1
+ assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"]
+ assert zha_device.available is True
+
+ # still no traffic, but zha_device is still available
+ _send_time_changed(hass, 91)
+ await hass.async_block_till_done()
+ assert basic_ch.read_attributes.await_count == 2
+ assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"]
+ assert zha_device.available is True
+
+ # not even trying to update, device is unavailble
+ _send_time_changed(hass, 91)
+ await hass.async_block_till_done()
+ assert basic_ch.read_attributes.await_count == 2
+ assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"]
+ assert zha_device.available is False
+
+
+@asynctest.patch(
+ "homeassistant.components.zha.core.channels.general.BasicChannel.async_initialize",
+ new=mock.MagicMock(),
+)
+async def test_check_available_no_basic_channel(
+ hass, device_without_basic_channel, zha_device_restored, caplog
+):
+ """Check device availability for a device without basic cluster."""
+
+ # pylint: disable=protected-access
+ zha_device = await zha_device_restored(device_without_basic_channel)
+ await async_enable_traffic(hass, [zha_device])
+
+ assert zha_device.available is True
+
+ device_without_basic_channel.last_seen = (
+ time.time() - zha_core_device._CONSIDER_UNAVAILABLE_BATTERY - 2
+ )
+
+ assert "does not have a mandatory basic cluster" not in caplog.text
+ _send_time_changed(hass, 91)
+ await hass.async_block_till_done()
+ assert zha_device.available is False
+ assert "does not have a mandatory basic cluster" in caplog.text
diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py
index 8866e6cff55..c779dda6cf8 100644
--- a/tests/components/zha/test_device_action.py
+++ b/tests/components/zha/test_device_action.py
@@ -11,7 +11,6 @@ from homeassistant.components.device_automation import (
_async_get_device_automations as async_get_device_automations,
)
from homeassistant.components.zha import DOMAIN
-from homeassistant.components.zha.core.const import CHANNEL_EVENT_RELAY
from homeassistant.helpers.device_registry import async_get_registry
from homeassistant.setup import async_setup_component
@@ -104,8 +103,8 @@ async def test_action(hass, device_ias):
await hass.async_block_till_done()
calls = async_mock_service(hass, DOMAIN, "warning_device_warn")
- channel = {ch.name: ch for ch in zha_device.all_channels}[CHANNEL_EVENT_RELAY]
- channel.zha_send_event(channel.cluster, COMMAND_SINGLE, [])
+ channel = zha_device.channels.pools[0].relay_channels["1:0x0006"]
+ channel.zha_send_event(COMMAND_SINGLE, [])
await hass.async_block_till_done()
assert len(calls) == 1
diff --git a/tests/components/zha/test_device_tracker.py b/tests/components/zha/test_device_tracker.py
index 3782cdc09a7..330153e5f8c 100644
--- a/tests/components/zha/test_device_tracker.py
+++ b/tests/components/zha/test_device_tracker.py
@@ -4,7 +4,6 @@ import time
import pytest
import zigpy.zcl.clusters.general as general
-import zigpy.zcl.foundation as zcl_f
from homeassistant.components.device_tracker import DOMAIN, SOURCE_TYPE_ROUTER
from homeassistant.components.zha.core.registries import (
@@ -17,8 +16,7 @@ from .common import (
async_enable_traffic,
async_test_rejoin,
find_entity_id,
- make_attribute,
- make_zcl_header,
+ send_attributes_report,
)
from tests.common import async_fire_time_changed
@@ -66,12 +64,9 @@ async def test_device_tracker(hass, zha_device_joined_restored, zigpy_device_dt)
assert hass.states.get(entity_id).state == STATE_NOT_HOME
# turn state flip
- attr = make_attribute(0x0020, 23)
- hdr = make_zcl_header(zcl_f.Command.Report_Attributes)
- cluster.handle_message(hdr, [[attr]])
-
- attr = make_attribute(0x0021, 200)
- cluster.handle_message(hdr, [[attr]])
+ await send_attributes_report(
+ hass, cluster, {0x0000: 0, 0x0020: 23, 0x0021: 200, 0x0001: 2}
+ )
zigpy_device_dt.last_seen = time.time() + 10
next_update = dt_util.utcnow() + timedelta(seconds=30)
diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py
index 4bb7567d1e6..9b69ba06e4f 100644
--- a/tests/components/zha/test_device_trigger.py
+++ b/tests/components/zha/test_device_trigger.py
@@ -3,7 +3,6 @@ import pytest
import zigpy.zcl.clusters.general as general
import homeassistant.components.automation as automation
-from homeassistant.components.zha.core.const import CHANNEL_EVENT_RELAY
from homeassistant.helpers.device_registry import async_get_registry
from homeassistant.setup import async_setup_component
@@ -173,8 +172,8 @@ async def test_if_fires_on_event(hass, mock_devices, calls):
await hass.async_block_till_done()
- channel = {ch.name: ch for ch in zha_device.all_channels}[CHANNEL_EVENT_RELAY]
- channel.zha_send_event(channel.cluster, COMMAND_SINGLE, [])
+ channel = zha_device.channels.pools[0].relay_channels["1:0x0006"]
+ channel.zha_send_event(COMMAND_SINGLE, [])
await hass.async_block_till_done()
assert len(calls) == 1
diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py
index a194453bd65..e1733ac44bd 100644
--- a/tests/components/zha/test_discover.py
+++ b/tests/components/zha/test_discover.py
@@ -3,11 +3,28 @@
import re
from unittest import mock
+import asynctest
import pytest
+import zigpy.quirks
+import zigpy.types
+import zigpy.zcl.clusters.closures
+import zigpy.zcl.clusters.general
+import zigpy.zcl.clusters.security
+import zigpy.zcl.foundation as zcl_f
+import homeassistant.components.zha.binary_sensor
+import homeassistant.components.zha.core.channels as zha_channels
+import homeassistant.components.zha.core.channels.base as base_channels
import homeassistant.components.zha.core.const as zha_const
import homeassistant.components.zha.core.discovery as disc
-import homeassistant.components.zha.core.gateway as core_zha_gw
+import homeassistant.components.zha.core.registries as zha_regs
+import homeassistant.components.zha.cover
+import homeassistant.components.zha.device_tracker
+import homeassistant.components.zha.fan
+import homeassistant.components.zha.light
+import homeassistant.components.zha.lock
+import homeassistant.components.zha.sensor
+import homeassistant.components.zha.switch
import homeassistant.helpers.entity_registry
from .common import get_zha_gateway
@@ -16,12 +33,40 @@ from .zha_devices_list import DEVICES
NO_TAIL_ID = re.compile("_\\d$")
+@pytest.fixture
+def channels_mock(zha_device_mock):
+ """Channels mock factory."""
+
+ def _mock(
+ endpoints,
+ ieee="00:11:22:33:44:55:66:77",
+ manufacturer="mock manufacturer",
+ model="mock model",
+ node_desc=b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00",
+ ):
+ zha_dev = zha_device_mock(endpoints, ieee, manufacturer, model, node_desc)
+ channels = zha_channels.Channels.new(zha_dev)
+ return channels
+
+ return _mock
+
+
+@asynctest.patch(
+ "zigpy.zcl.clusters.general.Identify.request",
+ new=asynctest.CoroutineMock(
+ return_value=[mock.sentinel.data, zcl_f.Status.SUCCESS]
+ ),
+)
@pytest.mark.parametrize("device", DEVICES)
async def test_devices(
device, hass, zigpy_device_mock, monkeypatch, zha_device_joined_restored
):
"""Test device discovery."""
+ entity_registry = await homeassistant.helpers.entity_registry.async_get_registry(
+ hass
+ )
+
zigpy_device = zigpy_device_mock(
device["endpoints"],
"00:11:22:33:44:55:66:77",
@@ -30,45 +75,324 @@ async def test_devices(
node_descriptor=device["node_descriptor"],
)
- _dispatch = mock.MagicMock(wraps=disc.async_dispatch_discovery_info)
- monkeypatch.setattr(core_zha_gw, "async_dispatch_discovery_info", _dispatch)
- entity_registry = await homeassistant.helpers.entity_registry.async_get_registry(
- hass
+ cluster_identify = _get_first_identify_cluster(zigpy_device)
+ if cluster_identify:
+ cluster_identify.request.reset_mock()
+
+ orig_new_entity = zha_channels.ChannelPool.async_new_entity
+ _dispatch = mock.MagicMock(wraps=orig_new_entity)
+ try:
+ zha_channels.ChannelPool.async_new_entity = lambda *a, **kw: _dispatch(*a, **kw)
+ zha_dev = await zha_device_joined_restored(zigpy_device)
+ await hass.async_block_till_done()
+ finally:
+ zha_channels.ChannelPool.async_new_entity = orig_new_entity
+
+ entity_ids = hass.states.async_entity_ids()
+ await hass.async_block_till_done()
+ zha_entity_ids = {
+ ent for ent in entity_ids if ent.split(".")[0] in zha_const.COMPONENTS
+ }
+
+ if cluster_identify:
+ called = int(zha_device_joined_restored.name == "zha_device_joined")
+ assert cluster_identify.request.call_count == called
+ assert cluster_identify.request.await_count == called
+ if called:
+ assert cluster_identify.request.call_args == mock.call(
+ False,
+ 64,
+ (zigpy.types.uint8_t, zigpy.types.uint8_t),
+ 2,
+ 0,
+ expect_reply=True,
+ manufacturer=None,
+ tsn=None,
+ )
+
+ event_channels = {
+ ch.id for pool in zha_dev.channels.pools for ch in pool.relay_channels.values()
+ }
+
+ entity_map = device["entity_map"]
+ assert zha_entity_ids == set(
+ [
+ e["entity_id"]
+ for e in entity_map.values()
+ if not e.get("default_match", False)
+ ]
)
+ assert event_channels == set(device["event_channels"])
+
+ for call in _dispatch.call_args_list:
+ _, component, entity_cls, unique_id, channels = call[0]
+ key = (component, unique_id)
+ entity_id = entity_registry.async_get_entity_id(component, "zha", unique_id)
+
+ assert key in entity_map
+ assert entity_id is not None
+ no_tail_id = NO_TAIL_ID.sub("", entity_map[key]["entity_id"])
+ assert entity_id.startswith(no_tail_id)
+ assert set([ch.name for ch in channels]) == set(entity_map[key]["channels"])
+ assert entity_cls.__name__ == entity_map[key]["entity_class"]
+
+
+def _get_first_identify_cluster(zigpy_device):
+ for endpoint in list(zigpy_device.endpoints.values())[1:]:
+ if hasattr(endpoint, "identify"):
+ return endpoint.identify
+
+
+@mock.patch(
+ "homeassistant.components.zha.core.discovery.ProbeEndpoint.discover_by_device_type"
+)
+@mock.patch(
+ "homeassistant.components.zha.core.discovery.ProbeEndpoint.discover_by_cluster_id"
+)
+def test_discover_entities(m1, m2):
+ """Test discover endpoint class method."""
+ ep_channels = mock.MagicMock()
+ disc.PROBE.discover_entities(ep_channels)
+ assert m1.call_count == 1
+ assert m1.call_args[0][0] is ep_channels
+ assert m2.call_count == 1
+ assert m2.call_args[0][0] is ep_channels
+
+
+@pytest.mark.parametrize(
+ "device_type, component, hit",
+ [
+ (0x0100, zha_const.LIGHT, True),
+ (0x0108, zha_const.SWITCH, True),
+ (0x0051, zha_const.SWITCH, True),
+ (0xFFFF, None, False),
+ ],
+)
+def test_discover_by_device_type(device_type, component, hit):
+ """Test entity discovery by device type."""
+
+ ep_channels = mock.MagicMock(spec_set=zha_channels.ChannelPool)
+ ep_mock = mock.PropertyMock()
+ ep_mock.return_value.profile_id = 0x0104
+ ep_mock.return_value.device_type = device_type
+ type(ep_channels).endpoint = ep_mock
+
+ get_entity_mock = mock.MagicMock(
+ return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed)
+ )
+ with mock.patch(
+ "homeassistant.components.zha.core.registries.ZHA_ENTITIES.get_entity",
+ get_entity_mock,
+ ):
+ disc.PROBE.discover_by_device_type(ep_channels)
+ if hit:
+ assert get_entity_mock.call_count == 1
+ assert ep_channels.claim_channels.call_count == 1
+ assert ep_channels.claim_channels.call_args[0][0] is mock.sentinel.claimed
+ assert ep_channels.async_new_entity.call_count == 1
+ assert ep_channels.async_new_entity.call_args[0][0] == component
+ assert ep_channels.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls
+
+
+def test_discover_by_device_type_override():
+ """Test entity discovery by device type overriding."""
+
+ ep_channels = mock.MagicMock(spec_set=zha_channels.ChannelPool)
+ ep_mock = mock.PropertyMock()
+ ep_mock.return_value.profile_id = 0x0104
+ ep_mock.return_value.device_type = 0x0100
+ type(ep_channels).endpoint = ep_mock
+
+ overrides = {ep_channels.unique_id: {"type": zha_const.SWITCH}}
+ get_entity_mock = mock.MagicMock(
+ return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed)
+ )
+ with mock.patch(
+ "homeassistant.components.zha.core.registries.ZHA_ENTITIES.get_entity",
+ get_entity_mock,
+ ):
+ with mock.patch.dict(disc.PROBE._device_configs, overrides, clear=True):
+ disc.PROBE.discover_by_device_type(ep_channels)
+ assert get_entity_mock.call_count == 1
+ assert ep_channels.claim_channels.call_count == 1
+ assert ep_channels.claim_channels.call_args[0][0] is mock.sentinel.claimed
+ assert ep_channels.async_new_entity.call_count == 1
+ assert ep_channels.async_new_entity.call_args[0][0] == zha_const.SWITCH
+ assert (
+ ep_channels.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls
+ )
+
+
+def test_discover_probe_single_cluster():
+ """Test entity discovery by single cluster."""
+
+ ep_channels = mock.MagicMock(spec_set=zha_channels.ChannelPool)
+ ep_mock = mock.PropertyMock()
+ ep_mock.return_value.profile_id = 0x0104
+ ep_mock.return_value.device_type = 0x0100
+ type(ep_channels).endpoint = ep_mock
+
+ get_entity_mock = mock.MagicMock(
+ return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed)
+ )
+ channel_mock = mock.MagicMock(spec_set=base_channels.ZigbeeChannel)
+ with mock.patch(
+ "homeassistant.components.zha.core.registries.ZHA_ENTITIES.get_entity",
+ get_entity_mock,
+ ):
+ disc.PROBE.probe_single_cluster(zha_const.SWITCH, channel_mock, ep_channels)
+
+ assert get_entity_mock.call_count == 1
+ assert ep_channels.claim_channels.call_count == 1
+ assert ep_channels.claim_channels.call_args[0][0] is mock.sentinel.claimed
+ assert ep_channels.async_new_entity.call_count == 1
+ assert ep_channels.async_new_entity.call_args[0][0] == zha_const.SWITCH
+ assert ep_channels.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls
+ assert ep_channels.async_new_entity.call_args[0][3] == mock.sentinel.claimed
+
+
+@pytest.mark.parametrize("device_info", DEVICES)
+async def test_discover_endpoint(device_info, channels_mock, hass):
+ """Test device discovery."""
with mock.patch(
- "homeassistant.components.zha.core.discovery._async_create_cluster_channel",
- wraps=disc._async_create_cluster_channel,
+ "homeassistant.components.zha.core.channels.Channels.async_new_entity"
+ ) as new_ent:
+ channels = channels_mock(
+ device_info["endpoints"],
+ manufacturer=device_info["manufacturer"],
+ model=device_info["model"],
+ node_desc=device_info["node_descriptor"],
+ )
+
+ assert device_info["event_channels"] == sorted(
+ [ch.id for pool in channels.pools for ch in pool.relay_channels.values()]
+ )
+ assert new_ent.call_count == len(
+ [
+ device_info
+ for device_info in device_info["entity_map"].values()
+ if not device_info.get("default_match", False)
+ ]
+ )
+
+ for call_args in new_ent.call_args_list:
+ comp, ent_cls, unique_id, channels = call_args[0]
+ map_id = (comp, unique_id)
+ assert map_id in device_info["entity_map"]
+ entity_info = device_info["entity_map"][map_id]
+ assert set([ch.name for ch in channels]) == set(entity_info["channels"])
+ assert ent_cls.__name__ == entity_info["entity_class"]
+
+
+def _ch_mock(cluster):
+ """Return mock of a channel with a cluster."""
+ channel = mock.MagicMock()
+ type(channel).cluster = mock.PropertyMock(return_value=cluster(mock.MagicMock()))
+ return channel
+
+
+@mock.patch(
+ "homeassistant.components.zha.core.discovery.ProbeEndpoint"
+ ".handle_on_off_output_cluster_exception",
+ new=mock.MagicMock(),
+)
+@mock.patch(
+ "homeassistant.components.zha.core.discovery.ProbeEndpoint.probe_single_cluster"
+)
+def _test_single_input_cluster_device_class(probe_mock):
+ """Test SINGLE_INPUT_CLUSTER_DEVICE_CLASS matching by cluster id or class."""
+
+ door_ch = _ch_mock(zigpy.zcl.clusters.closures.DoorLock)
+ cover_ch = _ch_mock(zigpy.zcl.clusters.closures.WindowCovering)
+ multistate_ch = _ch_mock(zigpy.zcl.clusters.general.MultistateInput)
+
+ class QuirkedIAS(zigpy.quirks.CustomCluster, zigpy.zcl.clusters.security.IasZone):
+ pass
+
+ ias_ch = _ch_mock(QuirkedIAS)
+
+ class _Analog(zigpy.quirks.CustomCluster, zigpy.zcl.clusters.general.AnalogInput):
+ pass
+
+ analog_ch = _ch_mock(_Analog)
+
+ ch_pool = mock.MagicMock(spec_set=zha_channels.ChannelPool)
+ ch_pool.unclaimed_channels.return_value = [
+ door_ch,
+ cover_ch,
+ multistate_ch,
+ ias_ch,
+ analog_ch,
+ ]
+
+ disc.ProbeEndpoint().discover_by_cluster_id(ch_pool)
+ assert probe_mock.call_count == len(ch_pool.unclaimed_channels())
+ probes = (
+ (zha_const.LOCK, door_ch),
+ (zha_const.COVER, cover_ch),
+ (zha_const.SENSOR, multistate_ch),
+ (zha_const.BINARY_SENSOR, ias_ch),
+ (zha_const.SENSOR, analog_ch),
+ )
+ for call, details in zip(probe_mock.call_args_list, probes):
+ component, ch = details
+ assert call[0][0] == component
+ assert call[0][1] == ch
+
+
+def test_single_input_cluster_device_class():
+ """Test SINGLE_INPUT_CLUSTER_DEVICE_CLASS matching by cluster id or class."""
+ _test_single_input_cluster_device_class()
+
+
+def test_single_input_cluster_device_class_by_cluster_class():
+ """Test SINGLE_INPUT_CLUSTER_DEVICE_CLASS matching by cluster id or class."""
+ mock_reg = {
+ zigpy.zcl.clusters.closures.DoorLock.cluster_id: zha_const.LOCK,
+ zigpy.zcl.clusters.closures.WindowCovering.cluster_id: zha_const.COVER,
+ zigpy.zcl.clusters.general.AnalogInput: zha_const.SENSOR,
+ zigpy.zcl.clusters.general.MultistateInput: zha_const.SENSOR,
+ zigpy.zcl.clusters.security.IasZone: zha_const.BINARY_SENSOR,
+ }
+
+ with mock.patch.dict(
+ zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS, mock_reg, clear=True
):
- await zha_device_joined_restored(zigpy_device)
- await hass.async_block_till_done()
+ _test_single_input_cluster_device_class()
- entity_ids = hass.states.async_entity_ids()
- await hass.async_block_till_done()
- zha_entities = {
- ent for ent in entity_ids if ent.split(".")[0] in zha_const.COMPONENTS
- }
- zha_gateway = get_zha_gateway(hass)
- zha_dev = zha_gateway.get_device(zigpy_device.ieee)
- event_channels = { # pylint: disable=protected-access
- ch.id for ch in zha_dev._relay_channels.values()
- }
+@pytest.mark.parametrize(
+ "override, entity_id",
+ [
+ (None, "light.manufacturer_model_77665544_level_light_color_on_off"),
+ ("switch", "switch.manufacturer_model_77665544_on_off"),
+ ],
+)
+async def test_device_override(hass, zigpy_device_mock, setup_zha, override, entity_id):
+ """Test device discovery override."""
- assert zha_entities == set(device["entities"])
- assert event_channels == set(device["event_channels"])
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "device_type": 258,
+ "endpoint_id": 1,
+ "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 64513],
+ "out_clusters": [25],
+ "profile_id": 260,
+ }
+ },
+ "00:11:22:33:44:55:66:77",
+ "manufacturer",
+ "model",
+ )
- entity_map = device["entity_map"]
- for calls in _dispatch.call_args_list:
- discovery_info = calls[0][2]
- unique_id = discovery_info["unique_id"]
- channels = discovery_info["channels"]
- component = discovery_info["component"]
- key = (component, unique_id)
- entity_id = entity_registry.async_get_entity_id(component, "zha", unique_id)
+ if override is not None:
+ override = {"device_config": {"00:11:22:33:44:55:66:77-1": {"type": override}}}
- assert key in entity_map
- assert entity_id is not None
- no_tail_id = NO_TAIL_ID.sub("", entity_map[key]["entity_id"])
- assert entity_id.startswith(no_tail_id)
- assert set([ch.name for ch in channels]) == set(entity_map[key]["channels"])
+ await setup_zha(override)
+ assert hass.states.get(entity_id) is None
+ zha_gateway = get_zha_gateway(hass)
+ await zha_gateway.async_device_initialized(zigpy_device)
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id) is not None
diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py
index 0cf3e3e954d..5011a847a4e 100644
--- a/tests/components/zha/test_fan.py
+++ b/tests/components/zha/test_fan.py
@@ -3,7 +3,6 @@ from unittest.mock import call
import pytest
import zigpy.zcl.clusters.hvac as hvac
-import zigpy.zcl.foundation as zcl_f
from homeassistant.components import fan
from homeassistant.components.fan import ATTR_SPEED, DOMAIN, SERVICE_SET_SPEED
@@ -20,8 +19,7 @@ from .common import (
async_enable_traffic,
async_test_rejoin,
find_entity_id,
- make_attribute,
- make_zcl_header,
+ send_attributes_report,
)
@@ -52,16 +50,11 @@ async def test_fan(hass, zha_device_joined_restored, zigpy_device):
assert hass.states.get(entity_id).state == STATE_OFF
# turn on at fan
- attr = make_attribute(0, 1)
- hdr = make_zcl_header(zcl_f.Command.Report_Attributes)
- cluster.handle_message(hdr, [[attr]])
- await hass.async_block_till_done()
+ await send_attributes_report(hass, cluster, {1: 2, 0: 1, 2: 3})
assert hass.states.get(entity_id).state == STATE_ON
# turn off at fan
- attr.value.value = 0
- cluster.handle_message(hdr, [[attr]])
- await hass.async_block_till_done()
+ await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 2})
assert hass.states.get(entity_id).state == STATE_OFF
# turn on from HA
diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py
index e21c22d30cf..f27bd329bdb 100644
--- a/tests/components/zha/test_light.py
+++ b/tests/components/zha/test_light.py
@@ -1,7 +1,8 @@
"""Test zha light."""
-from unittest.mock import call, sentinel
+from datetime import timedelta
+from unittest.mock import MagicMock, call, sentinel
-import asynctest
+from asynctest import CoroutineMock, patch
import pytest
import zigpy.profiles.zha
import zigpy.types
@@ -9,24 +10,31 @@ import zigpy.zcl.clusters.general as general
import zigpy.zcl.clusters.lighting as lighting
import zigpy.zcl.foundation as zcl_f
-from homeassistant.components.light import DOMAIN
+from homeassistant.components.light import DOMAIN, FLASH_LONG, FLASH_SHORT
+from homeassistant.components.zha.light import FLASH_EFFECTS
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE
+import homeassistant.util.dt as dt_util
from .common import (
async_enable_traffic,
async_test_rejoin,
find_entity_id,
- make_attribute,
- make_zcl_header,
+ send_attributes_report,
)
+from tests.common import async_fire_time_changed
+
ON = 1
OFF = 0
LIGHT_ON_OFF = {
1: {
"device_type": zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT,
- "in_clusters": [general.Basic.cluster_id, general.OnOff.cluster_id],
+ "in_clusters": [
+ general.Basic.cluster_id,
+ general.Identify.cluster_id,
+ general.OnOff.cluster_id,
+ ],
"out_clusters": [general.Ota.cluster_id],
}
}
@@ -48,6 +56,7 @@ LIGHT_COLOR = {
"device_type": zigpy.profiles.zha.DeviceType.COLOR_DIMMABLE_LIGHT,
"in_clusters": [
general.Basic.cluster_id,
+ general.Identify.cluster_id,
general.LevelControl.cluster_id,
general.OnOff.cluster_id,
lighting.Color.cluster_id,
@@ -57,24 +66,66 @@ LIGHT_COLOR = {
}
-@asynctest.patch(
+@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."""
+
+ # create zigpy devices
+ zigpy_device = zigpy_device_mock(LIGHT_ON_OFF)
+ zha_device = await zha_device_joined_restored(zigpy_device)
+ on_off_cluster = zigpy_device.endpoints[1].on_off
+ entity_id = await find_entity_id(DOMAIN, zha_device, hass)
+
+ # allow traffic to flow through the gateway and device
+ await async_enable_traffic(hass, [zha_device])
+ on_off_cluster.read_attributes.reset_mock()
+
+ # not enough time passed
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=20))
+ await hass.async_block_till_done()
+ assert on_off_cluster.read_attributes.call_count == 0
+ assert on_off_cluster.read_attributes.await_count == 0
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+ # 1 interval - 1 call
+ on_off_cluster.read_attributes.return_value = [{"on_off": 1}, {}]
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=80))
+ await hass.async_block_till_done()
+ assert on_off_cluster.read_attributes.call_count == 1
+ assert on_off_cluster.read_attributes.await_count == 1
+ assert hass.states.get(entity_id).state == STATE_ON
+
+ # 2 intervals - 2 calls
+ on_off_cluster.read_attributes.return_value = [{"on_off": 0}, {}]
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=80))
+ await hass.async_block_till_done()
+ assert on_off_cluster.read_attributes.call_count == 2
+ assert on_off_cluster.read_attributes.await_count == 2
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+
+@patch(
"zigpy.zcl.clusters.lighting.Color.request",
- new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
+ new=CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
)
-@asynctest.patch(
+@patch(
+ "zigpy.zcl.clusters.general.Identify.request",
+ new=CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
+)
+@patch(
"zigpy.zcl.clusters.general.LevelControl.request",
- new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
+ new=CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
)
-@asynctest.patch(
+@patch(
"zigpy.zcl.clusters.general.OnOff.request",
- new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
+ new=CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
)
@pytest.mark.parametrize(
"device, reporting",
[(LIGHT_ON_OFF, (1, 0, 0)), (LIGHT_LEVEL, (1, 1, 0)), (LIGHT_COLOR, (1, 1, 3))],
)
async def test_light(
- hass, zigpy_device_mock, zha_device_joined_restored, device, reporting,
+ hass, zigpy_device_mock, zha_device_joined_restored, device, reporting
):
"""Test zha light platform."""
@@ -88,6 +139,7 @@ async def test_light(
cluster_on_off = zigpy_device.endpoints[1].on_off
cluster_level = getattr(zigpy_device.endpoints[1], "level", None)
cluster_color = getattr(zigpy_device.endpoints[1], "light_color", None)
+ cluster_identify = getattr(zigpy_device.endpoints[1], "identify", None)
# test that the lights were created and that they are unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
@@ -104,6 +156,11 @@ async def test_light(
# test turning the lights on and off from the HA
await async_test_on_off_from_hass(hass, cluster_on_off, entity_id)
+ # test short flashing the lights from the HA
+ if cluster_identify:
+ await async_test_flash_from_hass(hass, cluster_identify, entity_id, FLASH_SHORT)
+
+ # test turning the lights on and off from the HA
if cluster_level:
await async_test_level_on_off_from_hass(
hass, cluster_on_off, cluster_level, entity_id
@@ -124,30 +181,26 @@ async def test_light(
clusters.append(cluster_color)
await async_test_rejoin(hass, zigpy_device, clusters, reporting)
+ # test long flashing the lights from the HA
+ if cluster_identify:
+ await async_test_flash_from_hass(hass, cluster_identify, entity_id, FLASH_LONG)
+
async def async_test_on_off_from_light(hass, cluster, entity_id):
"""Test on off functionality from the light."""
# turn on at light
- attr = make_attribute(0, 1)
- hdr = make_zcl_header(zcl_f.Command.Report_Attributes)
- cluster.handle_message(hdr, [[attr]])
- await hass.async_block_till_done()
+ await send_attributes_report(hass, cluster, {1: 0, 0: 1, 2: 3})
assert hass.states.get(entity_id).state == STATE_ON
# turn off at light
- attr.value.value = 0
- cluster.handle_message(hdr, [[attr]])
- await hass.async_block_till_done()
+ await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 3})
assert hass.states.get(entity_id).state == STATE_OFF
async def async_test_on_from_light(hass, cluster, entity_id):
"""Test on off functionality from the light."""
# turn on at light
- attr = make_attribute(0, 1)
- hdr = make_zcl_header(zcl_f.Command.Report_Attributes)
- cluster.handle_message(hdr, [[attr]])
- await hass.async_block_till_done()
+ await send_attributes_report(hass, cluster, {1: -1, 0: 1, 2: 2})
assert hass.states.get(entity_id).state == STATE_ON
@@ -161,7 +214,7 @@ async def async_test_on_off_from_hass(hass, cluster, entity_id):
assert cluster.request.call_count == 1
assert cluster.request.await_count == 1
assert cluster.request.call_args == call(
- False, ON, (), expect_reply=True, manufacturer=None
+ False, ON, (), expect_reply=True, manufacturer=None, tsn=None
)
await async_test_off_from_hass(hass, cluster, entity_id)
@@ -178,7 +231,7 @@ async def async_test_off_from_hass(hass, cluster, entity_id):
assert cluster.request.call_count == 1
assert cluster.request.await_count == 1
assert cluster.request.call_args == call(
- False, OFF, (), expect_reply=True, manufacturer=None
+ False, OFF, (), expect_reply=True, manufacturer=None, tsn=None
)
@@ -188,6 +241,7 @@ async def async_test_level_on_off_from_hass(
"""Test on off functionality from hass."""
on_off_cluster.request.reset_mock()
+ level_cluster.request.reset_mock()
# turn on via UI
await hass.services.async_call(
DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True
@@ -197,7 +251,7 @@ async def async_test_level_on_off_from_hass(
assert level_cluster.request.call_count == 0
assert level_cluster.request.await_count == 0
assert on_off_cluster.request.call_args == call(
- False, 1, (), expect_reply=True, manufacturer=None
+ False, ON, (), expect_reply=True, manufacturer=None, tsn=None
)
on_off_cluster.request.reset_mock()
level_cluster.request.reset_mock()
@@ -210,7 +264,7 @@ async def async_test_level_on_off_from_hass(
assert level_cluster.request.call_count == 1
assert level_cluster.request.await_count == 1
assert on_off_cluster.request.call_args == call(
- False, 1, (), expect_reply=True, manufacturer=None
+ False, ON, (), expect_reply=True, manufacturer=None, tsn=None
)
assert level_cluster.request.call_args == call(
False,
@@ -220,6 +274,7 @@ async def async_test_level_on_off_from_hass(
100.0,
expect_reply=True,
manufacturer=None,
+ tsn=None,
)
on_off_cluster.request.reset_mock()
level_cluster.request.reset_mock()
@@ -232,7 +287,7 @@ async def async_test_level_on_off_from_hass(
assert level_cluster.request.call_count == 1
assert level_cluster.request.await_count == 1
assert on_off_cluster.request.call_args == call(
- False, 1, (), expect_reply=True, manufacturer=None
+ False, ON, (), expect_reply=True, manufacturer=None, tsn=None
)
assert level_cluster.request.call_args == call(
False,
@@ -242,6 +297,7 @@ async def async_test_level_on_off_from_hass(
0,
expect_reply=True,
manufacturer=None,
+ tsn=None,
)
on_off_cluster.request.reset_mock()
level_cluster.request.reset_mock()
@@ -251,12 +307,33 @@ async def async_test_level_on_off_from_hass(
async def async_test_dimmer_from_light(hass, cluster, entity_id, level, expected_state):
"""Test dimmer functionality from the light."""
- attr = make_attribute(0, level)
- hdr = make_zcl_header(zcl_f.Command.Report_Attributes)
- cluster.handle_message(hdr, [[attr]])
- await hass.async_block_till_done()
+
+ await send_attributes_report(
+ hass, cluster, {1: level + 10, 0: level, 2: level - 10 or 22}
+ )
assert hass.states.get(entity_id).state == expected_state
# hass uses None for brightness of 0 in state attributes
if level == 0:
level = None
assert hass.states.get(entity_id).attributes.get("brightness") == level
+
+
+async def async_test_flash_from_hass(hass, cluster, entity_id, flash):
+ """Test flash functionality from hass."""
+ # turn on via UI
+ cluster.request.reset_mock()
+ await hass.services.async_call(
+ DOMAIN, "turn_on", {"entity_id": entity_id, "flash": flash}, blocking=True
+ )
+ assert cluster.request.call_count == 1
+ assert cluster.request.await_count == 1
+ assert cluster.request.call_args == call(
+ False,
+ 64,
+ (zigpy.types.uint8_t, zigpy.types.uint8_t),
+ FLASH_EFFECTS[flash],
+ 0,
+ expect_reply=True,
+ manufacturer=None,
+ tsn=None,
+ )
diff --git a/tests/components/zha/test_lock.py b/tests/components/zha/test_lock.py
index 0442ea497d7..86ec266ffa2 100644
--- a/tests/components/zha/test_lock.py
+++ b/tests/components/zha/test_lock.py
@@ -10,12 +10,7 @@ import zigpy.zcl.foundation as zcl_f
from homeassistant.components.lock import DOMAIN
from homeassistant.const import STATE_LOCKED, STATE_UNAVAILABLE, STATE_UNLOCKED
-from .common import (
- async_enable_traffic,
- find_entity_id,
- make_attribute,
- make_zcl_header,
-)
+from .common import async_enable_traffic, find_entity_id, send_attributes_report
from tests.common import mock_coro
@@ -58,16 +53,11 @@ async def test_lock(hass, lock):
assert hass.states.get(entity_id).state == STATE_UNLOCKED
# set state to locked
- attr = make_attribute(0, 1)
- hdr = make_zcl_header(zcl_f.Command.Report_Attributes)
- cluster.handle_message(hdr, [[attr]])
- await hass.async_block_till_done()
+ await send_attributes_report(hass, cluster, {1: 0, 0: 1, 2: 2})
assert hass.states.get(entity_id).state == STATE_LOCKED
# set state to unlocked
- attr.value.value = 2
- cluster.handle_message(hdr, [[attr]])
- await hass.async_block_till_done()
+ await send_attributes_report(hass, cluster, {1: 0, 0: 2, 2: 3})
assert hass.states.get(entity_id).state == STATE_UNLOCKED
# lock from HA
diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py
index 383b61e6c66..fc41a409518 100644
--- a/tests/components/zha/test_registries.py
+++ b/tests/components/zha/test_registries.py
@@ -55,8 +55,20 @@ def channels(channel):
# manufacturer matching
(registries.MatchRule(manufacturers="no match"), False),
(registries.MatchRule(manufacturers=MANUFACTURER), True),
+ (
+ registries.MatchRule(manufacturers="no match", aux_channels="aux_channel"),
+ False,
+ ),
+ (
+ registries.MatchRule(
+ manufacturers=MANUFACTURER, aux_channels="aux_channel"
+ ),
+ True,
+ ),
(registries.MatchRule(models=MODEL), True),
(registries.MatchRule(models="no match"), False),
+ (registries.MatchRule(models=MODEL, aux_channels="aux_channel"), True),
+ (registries.MatchRule(models="no match", aux_channels="aux_channel"), False),
# match everything
(
registries.MatchRule(
@@ -113,10 +125,9 @@ def channels(channel):
),
],
)
-def test_registry_matching(rule, matched, zha_device, channels):
+def test_registry_matching(rule, matched, channels):
"""Test strict rule matching."""
- reg = registries.ZHAEntityRegistry()
- assert reg._strict_matched(zha_device, channels, rule) is matched
+ assert rule.strict_matched(MANUFACTURER, MODEL, channels) is matched
@pytest.mark.parametrize(
@@ -197,7 +208,49 @@ def test_registry_matching(rule, matched, zha_device, channels):
),
],
)
-def test_registry_loose_matching(rule, matched, zha_device, channels):
+def test_registry_loose_matching(rule, matched, channels):
"""Test loose rule matching."""
- reg = registries.ZHAEntityRegistry()
- assert reg._loose_matched(zha_device, channels, rule) is matched
+ assert rule.loose_matched(MANUFACTURER, MODEL, channels) is matched
+
+
+def test_match_rule_claim_channels_color(channel):
+ """Test channel claiming."""
+ ch_color = channel("color", 0x300)
+ ch_level = channel("level", 8)
+ ch_onoff = channel("on_off", 6)
+
+ rule = registries.MatchRule(channel_names="on_off", aux_channels={"color", "level"})
+ claimed = rule.claim_channels([ch_color, ch_level, ch_onoff])
+ assert {"color", "level", "on_off"} == set([ch.name for ch in claimed])
+
+
+@pytest.mark.parametrize(
+ "rule, match",
+ [
+ (registries.MatchRule(channel_names={"level"}), {"level"}),
+ (registries.MatchRule(channel_names={"level", "no match"}), {"level"}),
+ (registries.MatchRule(channel_names={"on_off"}), {"on_off"}),
+ (registries.MatchRule(generic_ids="channel_0x0000"), {"basic"}),
+ (
+ registries.MatchRule(channel_names="level", generic_ids="channel_0x0000"),
+ {"basic", "level"},
+ ),
+ (registries.MatchRule(channel_names={"level", "power"}), {"level", "power"}),
+ (
+ registries.MatchRule(
+ channel_names={"level", "on_off"}, aux_channels={"basic", "power"}
+ ),
+ {"basic", "level", "on_off", "power"},
+ ),
+ (registries.MatchRule(channel_names={"color"}), set()),
+ ],
+)
+def test_match_rule_claim_channels(rule, match, channel, channels):
+ """Test channel claiming."""
+ ch_basic = channel("basic", 0)
+ channels.append(ch_basic)
+ ch_power = channel("power", 1)
+ channels.append(ch_power)
+
+ claimed = rule.claim_channels(channels)
+ assert match == set([ch.name for ch in claimed])
diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py
index b81e8f02c12..50b85f5720f 100644
--- a/tests/components/zha/test_sensor.py
+++ b/tests/components/zha/test_sensor.py
@@ -6,7 +6,6 @@ import zigpy.zcl.clusters.general as general
import zigpy.zcl.clusters.homeautomation as homeautomation
import zigpy.zcl.clusters.measurement as measurement
import zigpy.zcl.clusters.smartenergy as smartenergy
-import zigpy.zcl.foundation as zcl_f
from homeassistant.components.sensor import DOMAIN
import homeassistant.config as config_util
@@ -19,6 +18,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
+ UNIT_PERCENTAGE,
)
from homeassistant.helpers import restore_state
from homeassistant.util import dt as dt_util
@@ -27,38 +27,41 @@ from .common import (
async_enable_traffic,
async_test_rejoin,
find_entity_id,
- make_attribute,
- make_zcl_header,
+ send_attribute_report,
+ send_attributes_report,
)
async def async_test_humidity(hass, cluster, entity_id):
"""Test humidity sensor."""
- await send_attribute_report(hass, cluster, 0, 1000)
- assert_state(hass, entity_id, "10.0", "%")
+ await send_attributes_report(hass, cluster, {1: 1, 0: 1000, 2: 100})
+ assert_state(hass, entity_id, "10.0", UNIT_PERCENTAGE)
async def async_test_temperature(hass, cluster, entity_id):
"""Test temperature sensor."""
- await send_attribute_report(hass, cluster, 0, 2900)
+ await send_attributes_report(hass, cluster, {1: 1, 0: 2900, 2: 100})
assert_state(hass, entity_id, "29.0", "°C")
async def async_test_pressure(hass, cluster, entity_id):
"""Test pressure sensor."""
- await send_attribute_report(hass, cluster, 0, 1000)
+ await send_attributes_report(hass, cluster, {1: 1, 0: 1000, 2: 10000})
+ assert_state(hass, entity_id, "1000", "hPa")
+
+ await send_attributes_report(hass, cluster, {0: 1000, 20: -1, 16: 10000})
assert_state(hass, entity_id, "1000", "hPa")
async def async_test_illuminance(hass, cluster, entity_id):
"""Test illuminance sensor."""
- await send_attribute_report(hass, cluster, 0, 10)
+ await send_attributes_report(hass, cluster, {1: 1, 0: 10, 2: 20})
assert_state(hass, entity_id, "1.0", "lx")
async def async_test_metering(hass, cluster, entity_id):
"""Test metering sensor."""
- await send_attribute_report(hass, cluster, 1024, 12345)
+ await send_attributes_report(hass, cluster, {1025: 1, 1024: 12345, 1026: 100})
assert_state(hass, entity_id, "12345.0", "unknown")
@@ -72,17 +75,17 @@ async def async_test_electrical_measurement(hass, cluster, entity_id):
new_callable=mock.PropertyMock,
) as divisor_mock:
divisor_mock.return_value = 1
- await send_attribute_report(hass, cluster, 1291, 100)
+ await send_attributes_report(hass, cluster, {0: 1, 1291: 100, 10: 1000})
assert_state(hass, entity_id, "100", "W")
- await send_attribute_report(hass, cluster, 1291, 99)
+ await send_attributes_report(hass, cluster, {0: 1, 1291: 99, 10: 1000})
assert_state(hass, entity_id, "99", "W")
divisor_mock.return_value = 10
- await send_attribute_report(hass, cluster, 1291, 1000)
+ await send_attributes_report(hass, cluster, {0: 1, 1291: 1000, 10: 5000})
assert_state(hass, entity_id, "100", "W")
- await send_attribute_report(hass, cluster, 1291, 99)
+ await send_attributes_report(hass, cluster, {0: 1, 1291: 99, 10: 5000})
assert_state(hass, entity_id, "9.9", "W")
@@ -140,18 +143,6 @@ async def test_sensor(
await async_test_rejoin(hass, zigpy_device, [cluster], (report_count,))
-async def send_attribute_report(hass, cluster, attrid, value):
- """Cause the sensor to receive an attribute report from the network.
-
- This is to simulate the normal device communication that happens when a
- device is paired to the zigbee network.
- """
- attr = make_attribute(attrid, value)
- hdr = make_zcl_header(zcl_f.Command.Report_Attributes)
- cluster.handle_message(hdr, [[attr]])
- await hass.async_block_till_done()
-
-
def assert_state(hass, entity_id, state, unit_of_measurement):
"""Check that the state is what is expected.
diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py
index a088283834b..98f661cc1ab 100644
--- a/tests/components/zha/test_switch.py
+++ b/tests/components/zha/test_switch.py
@@ -12,8 +12,7 @@ from .common import (
async_enable_traffic,
async_test_rejoin,
find_entity_id,
- make_attribute,
- make_zcl_header,
+ send_attributes_report,
)
from tests.common import mock_coro
@@ -53,16 +52,11 @@ async def test_switch(hass, zha_device_joined_restored, zigpy_device):
assert hass.states.get(entity_id).state == STATE_OFF
# turn on at switch
- attr = make_attribute(0, 1)
- hdr = make_zcl_header(zcl_f.Command.Report_Attributes)
- cluster.handle_message(hdr, [[attr]])
- await hass.async_block_till_done()
+ await send_attributes_report(hass, cluster, {1: 0, 0: 1, 2: 2})
assert hass.states.get(entity_id).state == STATE_ON
# turn off at switch
- attr.value.value = 0
- cluster.handle_message(hdr, [[attr]])
- await hass.async_block_till_done()
+ await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 2})
assert hass.states.get(entity_id).state == STATE_OFF
# turn on from HA
@@ -76,7 +70,7 @@ async def test_switch(hass, zha_device_joined_restored, zigpy_device):
)
assert len(cluster.request.mock_calls) == 1
assert cluster.request.call_args == call(
- False, ON, (), expect_reply=True, manufacturer=None
+ False, ON, (), expect_reply=True, manufacturer=None, tsn=None
)
# turn off from HA
@@ -90,7 +84,7 @@ async def test_switch(hass, zha_device_joined_restored, zigpy_device):
)
assert len(cluster.request.mock_calls) == 1
assert cluster.request.call_args == call(
- False, OFF, (), expect_reply=True, manufacturer=None
+ False, OFF, (), expect_reply=True, manufacturer=None, tsn=None
)
# test joining a new switch to the network and HA
diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py
index a8c83406435..b92fc64dee2 100644
--- a/tests/components/zha/zha_devices_list.py
+++ b/tests/components/zha/zha_devices_list.py
@@ -523,7 +523,7 @@ DEVICES = [
"channels": ["ias_zone"],
"entity_class": "IASZone",
"entity_id": "binary_sensor.heiman_co_v16_77665544_ias_zone",
- },
+ }
},
"event_channels": [],
"manufacturer": "Heiman",
@@ -547,7 +547,7 @@ DEVICES = [
"channels": ["ias_zone"],
"entity_class": "IASZone",
"entity_id": "binary_sensor.heiman_warningdevice_77665544_ias_zone",
- },
+ }
},
"event_channels": [],
"manufacturer": "Heiman",
@@ -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": [],
+ "event_channels": ["1:0x0005"],
"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": [],
+ "event_channels": ["1:0x0005"],
"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": [],
+ "event_channels": ["1:0x0005"],
"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": [],
+ "event_channels": ["1:0x0005"],
"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": [],
+ "event_channels": ["1:0x0005"],
"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": [],
+ "event_channels": ["1:0x0005"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI control outlet",
"node_descriptor": b"\x01@\x8e|\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:0x0006", "1:0x0008"],
+ "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008"],
"manufacturer": "IKEA of Sweden",
"model": "TRADFRI remote control",
"node_descriptor": b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00",
@@ -1036,7 +1036,6 @@ DEVICES = [
}
},
"entities": [
- "binary_sensor.keen_home_inc_sv02_610_mp_1_3_77665544_manufacturer_specific",
"light.keen_home_inc_sv02_610_mp_1_3_77665544_level_on_off",
"sensor.keen_home_inc_sv02_610_mp_1_3_77665544_power",
"sensor.keen_home_inc_sv02_610_mp_1_3_77665544_pressure",
@@ -1063,12 +1062,6 @@ DEVICES = [
"entity_class": "Pressure",
"entity_id": "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_pressure",
},
- ("binary_sensor", "00:11:22:33:44:55:66:77-1-64514"): {
- "channels": ["manufacturer_specific"],
- "entity_class": "BinarySensor",
- "entity_id": "binary_sensor.keen_home_inc_sv02_610_mp_1_3_77665544_manufacturer_specific",
- "default_match": True,
- },
},
"event_channels": [],
"manufacturer": "Keen Home Inc",
@@ -1101,7 +1094,6 @@ DEVICES = [
}
},
"entities": [
- "binary_sensor.keen_home_inc_sv02_612_mp_1_2_77665544_manufacturer_specific",
"light.keen_home_inc_sv02_612_mp_1_2_77665544_level_on_off",
"sensor.keen_home_inc_sv02_612_mp_1_2_77665544_power",
"sensor.keen_home_inc_sv02_612_mp_1_2_77665544_pressure",
@@ -1128,12 +1120,6 @@ DEVICES = [
"entity_class": "Pressure",
"entity_id": "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_pressure",
},
- ("binary_sensor", "00:11:22:33:44:55:66:77-1-64514"): {
- "channels": ["manufacturer_specific"],
- "entity_class": "BinarySensor",
- "entity_id": "binary_sensor.keen_home_inc_sv02_612_mp_1_2_77665544_manufacturer_specific",
- "default_match": True,
- },
},
"event_channels": [],
"manufacturer": "Keen Home Inc",
@@ -1166,7 +1152,6 @@ DEVICES = [
}
},
"entities": [
- "binary_sensor.keen_home_inc_sv02_612_mp_1_3_77665544_manufacturer_specific",
"light.keen_home_inc_sv02_612_mp_1_3_77665544_level_on_off",
"sensor.keen_home_inc_sv02_612_mp_1_3_77665544_power",
"sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure",
@@ -1193,12 +1178,6 @@ DEVICES = [
"entity_class": "Pressure",
"entity_id": "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure",
},
- ("binary_sensor", "00:11:22:33:44:55:66:77-1-64514"): {
- "channels": ["manufacturer_specific"],
- "entity_class": "BinarySensor",
- "entity_id": "binary_sensor.keen_home_inc_sv02_612_mp_1_3_77665544_manufacturer_specific",
- "default_match": True,
- },
},
"event_channels": [],
"manufacturer": "Keen Home Inc",
@@ -1531,7 +1510,7 @@ DEVICES = [
"entity_id": "sensor.lumi_lumi_remote_b186acn01_77665544_multistate_input",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0005", "2:0x0005", "3:0x0005"],
"manufacturer": "LUMI",
"model": "lumi.remote.b186acn01",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -1590,7 +1569,7 @@ DEVICES = [
"entity_id": "sensor.lumi_lumi_remote_b286acn01_77665544_multistate_input",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0005", "2:0x0005", "3:0x0005"],
"manufacturer": "LUMI",
"model": "lumi.remote.b286acn01",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -1784,13 +1763,21 @@ DEVICES = [
"profile_id": 260,
}
},
- "entities": ["light.lumi_lumi_router_77665544_on_off_on_off"],
+ "entities": [
+ "binary_sensor.lumi_lumi_router_77665544_on_off",
+ "light.lumi_lumi_router_77665544_on_off",
+ ],
"entity_map": {
+ ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): {
+ "channels": ["on_off", "on_off"],
+ "entity_class": "Opening",
+ "entity_id": "binary_sensor.lumi_lumi_router_77665544_on_off",
+ },
("light", "00:11:22:33:44:55:66:77-8"): {
"channels": ["on_off", "on_off"],
"entity_class": "Light",
- "entity_id": "light.lumi_lumi_router_77665544_on_off_on_off",
- }
+ "entity_id": "light.lumi_lumi_router_77665544_on_off",
+ },
},
"event_channels": ["8:0x0006"],
"manufacturer": "LUMI",
@@ -1808,13 +1795,21 @@ DEVICES = [
"profile_id": 260,
}
},
- "entities": ["light.lumi_lumi_router_77665544_on_off_on_off"],
+ "entities": [
+ "binary_sensor.lumi_lumi_router_77665544_on_off",
+ "light.lumi_lumi_router_77665544_on_off",
+ ],
"entity_map": {
+ ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): {
+ "channels": ["on_off", "on_off"],
+ "entity_class": "Opening",
+ "entity_id": "binary_sensor.lumi_lumi_router_77665544_on_off",
+ },
("light", "00:11:22:33:44:55:66:77-8"): {
"channels": ["on_off", "on_off"],
"entity_class": "Light",
- "entity_id": "light.lumi_lumi_router_77665544_on_off_on_off",
- }
+ "entity_id": "light.lumi_lumi_router_77665544_on_off",
+ },
},
"event_channels": ["8:0x0006"],
"manufacturer": "LUMI",
@@ -1832,13 +1827,21 @@ DEVICES = [
"profile_id": 260,
}
},
- "entities": ["light.lumi_lumi_router_77665544_on_off_on_off"],
+ "entities": [
+ "binary_sensor.lumi_lumi_router_77665544_on_off",
+ "light.lumi_lumi_router_77665544_on_off",
+ ],
"entity_map": {
+ ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): {
+ "channels": ["on_off", "on_off"],
+ "entity_class": "Opening",
+ "entity_id": "binary_sensor.lumi_lumi_router_77665544_on_off",
+ },
("light", "00:11:22:33:44:55:66:77-8"): {
"channels": ["on_off", "on_off"],
"entity_class": "Light",
- "entity_id": "light.lumi_lumi_router_77665544_on_off_on_off",
- }
+ "entity_id": "light.lumi_lumi_router_77665544_on_off",
+ },
},
"event_channels": ["8:0x0006"],
"manufacturer": "LUMI",
@@ -1862,7 +1865,7 @@ DEVICES = [
"channels": ["illuminance"],
"entity_class": "Illuminance",
"entity_id": "sensor.lumi_lumi_sen_ill_mgl01_77665544_illuminance",
- },
+ }
},
"event_channels": [],
"manufacturer": "LUMI",
@@ -1922,7 +1925,7 @@ DEVICES = [
"entity_id": "sensor.lumi_lumi_sensor_86sw1_77665544_multistate_input",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0005", "2:0x0005", "3:0x0005"],
"manufacturer": "LUMI",
"model": "lumi.sensor_86sw1",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -1975,7 +1978,7 @@ DEVICES = [
"entity_id": "sensor.lumi_lumi_sensor_cube_aqgl01_77665544_analog_input",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0005", "2:0x0005", "3:0x0005"],
"manufacturer": "LUMI",
"model": "lumi.sensor_cube.aqgl01",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -2028,7 +2031,7 @@ DEVICES = [
"entity_id": "sensor.lumi_lumi_sensor_ht_77665544_humidity",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0005", "2:0x0005", "3:0x0005"],
"manufacturer": "LUMI",
"model": "lumi.sensor_ht",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -2061,7 +2064,7 @@ DEVICES = [
"entity_id": "binary_sensor.lumi_lumi_sensor_magnet_77665544_on_off",
},
},
- "event_channels": ["1:0x0006", "1:0x0008"],
+ "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008"],
"manufacturer": "LUMI",
"model": "lumi.sensor_magnet",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -2209,7 +2212,7 @@ DEVICES = [
"entity_id": "sensor.lumi_lumi_sensor_switch_77665544_power",
}
},
- "event_channels": ["1:0x0006", "1:0x0008"],
+ "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008"],
"manufacturer": "LUMI",
"model": "lumi.sensor_switch",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -2346,7 +2349,7 @@ DEVICES = [
"entity_id": "binary_sensor.lumi_lumi_vibration_aq1_77665544_ias_zone",
},
},
- "event_channels": [],
+ "event_channels": ["1:0x0005", "2:0x0005"],
"manufacturer": "LUMI",
"model": "lumi.vibration.aq1",
"node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00",
@@ -2701,21 +2704,27 @@ DEVICES = [
}
},
"event_channels": [
+ "1:0x0005",
"1:0x0006",
"1:0x0008",
"1:0x0300",
+ "2:0x0005",
"2:0x0006",
"2:0x0008",
"2:0x0300",
+ "3:0x0005",
"3:0x0006",
"3:0x0008",
"3:0x0300",
+ "4:0x0005",
"4:0x0006",
"4:0x0008",
"4:0x0300",
+ "5:0x0005",
"5:0x0006",
"5:0x0008",
"5:0x0300",
+ "6:0x0005",
"6:0x0006",
"6:0x0008",
"6:0x0300",
@@ -2751,7 +2760,7 @@ DEVICES = [
"entity_id": "sensor.philips_rwl020_77665544_power",
}
},
- "event_channels": ["1:0x0006", "1:0x0008"],
+ "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008"],
"manufacturer": "Philips",
"model": "RWL020",
"node_descriptor": b"\x02@\x80\x0b\x10G-\x00\x00\x00-\x00\x00",
@@ -2907,7 +2916,7 @@ DEVICES = [
"entity_id": "sensor.securifi_ltd_unk_model_77665544_electrical_measurement",
},
},
- "event_channels": ["1:0x0006"],
+ "event_channels": ["1:0x0005", "1:0x0006"],
"manufacturer": "Securifi Ltd.",
"model": None,
"node_descriptor": b"\x01@\x8e\x02\x10RR\x00\x00\x00R\x00\x00",
diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py
index a8f72d2105c..4d358bde770 100644
--- a/tests/components/zwave/test_init.py
+++ b/tests/components/zwave/test_init.py
@@ -1797,6 +1797,31 @@ class TestZWaveServices(unittest.TestCase):
assert self.zwave_network.nodes[14].values[12].data == 2
+ def test_set_node_value_with_long_id_and_text_value(self):
+ """Test zwave set_node_value service."""
+ value = MockValue(
+ index=87512398541236578,
+ command_class=const.COMMAND_CLASS_SWITCH_COLOR,
+ data="#ff0000",
+ )
+ node = MockNode(node_id=14, command_classes=[const.COMMAND_CLASS_SWITCH_COLOR])
+ node.values = {87512398541236578: value}
+ node.get_values.return_value = node.values
+ self.zwave_network.nodes = {14: node}
+
+ self.hass.services.call(
+ "zwave",
+ "set_node_value",
+ {
+ const.ATTR_NODE_ID: 14,
+ const.ATTR_VALUE_ID: "87512398541236578",
+ const.ATTR_CONFIG_VALUE: "#00ff00",
+ },
+ )
+ self.hass.block_till_done()
+
+ assert self.zwave_network.nodes[14].values[87512398541236578].data == "#00ff00"
+
def test_refresh_node_value(self):
"""Test zwave refresh_node_value service."""
node = MockNode(
diff --git a/tests/components/zwave/test_sensor.py b/tests/components/zwave/test_sensor.py
index 74e1ef2cd03..d7503eb10fb 100644
--- a/tests/components/zwave/test_sensor.py
+++ b/tests/components/zwave/test_sensor.py
@@ -151,12 +151,12 @@ def test_alarm_sensor_value_changed(mock_openzwave):
node = MockNode(
command_classes=[const.COMMAND_CLASS_ALARM, const.COMMAND_CLASS_SENSOR_ALARM]
)
- value = MockValue(data=12.34, node=node, units="%")
+ value = MockValue(data=12.34, node=node, units=homeassistant.const.UNIT_PERCENTAGE)
values = MockEntityValues(primary=value)
device = sensor.get_device(node=node, values=values, node_config={})
assert device.state == 12.34
- assert device.unit_of_measurement == "%"
+ assert device.unit_of_measurement == homeassistant.const.UNIT_PERCENTAGE
value.data = 45.67
value_changed(value)
assert device.state == 45.67
diff --git a/tests/fixtures/august/get_activity.doorbell_motion.json b/tests/fixtures/august/get_activity.doorbell_motion.json
new file mode 100644
index 00000000000..bd9c07afa26
--- /dev/null
+++ b/tests/fixtures/august/get_activity.doorbell_motion.json
@@ -0,0 +1,58 @@
+[
+ {
+ "otherUser" : {
+ "FirstName" : "Unknown",
+ "UserName" : "deleteduser",
+ "LastName" : "User",
+ "UserID" : "deleted",
+ "PhoneNo" : "deleted"
+ },
+ "dateTime" : 1582663119959,
+ "deviceID" : "K98GiDT45GUL",
+ "info" : {
+ "videoUploadProgress" : "in_progress",
+ "image" : {
+ "resource_type" : "image",
+ "etag" : "fdsf",
+ "created_at" : "2020-02-25T20:38:39Z",
+ "type" : "upload",
+ "format" : "jpg",
+ "version" : 1582663119,
+ "secure_url" : "https://res.cloudinary.com/updated_image.jpg",
+ "signature" : "fdfdfd",
+ "url" : "http://res.cloudinary.com/updated_image.jpg",
+ "bytes" : 48545,
+ "placeholder" : false,
+ "original_filename" : "file",
+ "width" : 720,
+ "tags" : [],
+ "public_id" : "xnsj5gphpzij9brifpf4",
+ "height" : 576
+ },
+ "dvrID" : "dvr",
+ "videoAvailable" : false,
+ "hasSubscription" : false
+ },
+ "callingUser" : {
+ "LastName" : "User",
+ "UserName" : "deleteduser",
+ "FirstName" : "Unknown",
+ "UserID" : "deleted",
+ "PhoneNo" : "deleted"
+ },
+ "house" : {
+ "houseName" : "K98GiDT45GUL",
+ "houseID" : "na"
+ },
+ "action" : "doorbell_motion_detected",
+ "deviceType" : "doorbell",
+ "entities" : {
+ "otherUser" : "deleted",
+ "house" : "na",
+ "device" : "K98GiDT45GUL",
+ "activity" : "de5585cfd4eae900bb5ba3dc",
+ "callingUser" : "deleted"
+ },
+ "deviceName" : "Front Door"
+ }
+]
diff --git a/tests/fixtures/august/get_activity.lock.json b/tests/fixtures/august/get_activity.lock.json
new file mode 100644
index 00000000000..e0e61cb36b3
--- /dev/null
+++ b/tests/fixtures/august/get_activity.lock.json
@@ -0,0 +1,34 @@
+[{
+ "entities" : {
+ "activity" : "mockActivity2",
+ "house" : "123",
+ "device" : "online_with_doorsense",
+ "callingUser" : "mockUserId2",
+ "otherUser" : "deleted"
+ },
+ "callingUser" : {
+ "LastName" : "elven princess",
+ "UserID" : "mockUserId2",
+ "FirstName" : "Your favorite"
+ },
+ "otherUser" : {
+ "LastName" : "User",
+ "UserName" : "deleteduser",
+ "FirstName" : "Unknown",
+ "UserID" : "deleted",
+ "PhoneNo" : "deleted"
+ },
+ "deviceType" : "lock",
+ "deviceName" : "MockHouseTDoor",
+ "action" : "lock",
+ "dateTime" : 1582007218000,
+ "info" : {
+ "remote" : true,
+ "DateLogActionID" : "ABC+Time"
+ },
+ "deviceID" : "online_with_doorsense",
+ "house" : {
+ "houseName" : "MockHouse",
+ "houseID" : "123"
+ }
+}]
diff --git a/tests/fixtures/august/get_activity.lock_from_autorelock.json b/tests/fixtures/august/get_activity.lock_from_autorelock.json
new file mode 100644
index 00000000000..1c5d19344dc
--- /dev/null
+++ b/tests/fixtures/august/get_activity.lock_from_autorelock.json
@@ -0,0 +1,34 @@
+[{
+ "entities" : {
+ "activity" : "mockActivity2",
+ "house" : "123",
+ "device" : "online_with_doorsense",
+ "callingUser" : "mockUserId2",
+ "otherUser" : "deleted"
+ },
+ "callingUser" : {
+ "LastName" : "Relock",
+ "UserID" : "automaticrelock",
+ "FirstName" : "Auto"
+ },
+ "otherUser" : {
+ "LastName" : "User",
+ "UserName" : "deleteduser",
+ "FirstName" : "Unknown",
+ "UserID" : "deleted",
+ "PhoneNo" : "deleted"
+ },
+ "deviceType" : "lock",
+ "deviceName" : "MockHouseTDoor",
+ "action" : "lock",
+ "dateTime" : 1582007218000,
+ "info" : {
+ "remote" : false,
+ "DateLogActionID" : "ABC+Time"
+ },
+ "deviceID" : "online_with_doorsense",
+ "house" : {
+ "houseName" : "MockHouse",
+ "houseID" : "123"
+ }
+}]
diff --git a/tests/fixtures/august/get_activity.lock_from_bluetooth.json b/tests/fixtures/august/get_activity.lock_from_bluetooth.json
new file mode 100644
index 00000000000..f48d8da1319
--- /dev/null
+++ b/tests/fixtures/august/get_activity.lock_from_bluetooth.json
@@ -0,0 +1,34 @@
+[{
+ "entities" : {
+ "activity" : "mockActivity2",
+ "house" : "123",
+ "device" : "online_with_doorsense",
+ "callingUser" : "mockUserId2",
+ "otherUser" : "deleted"
+ },
+ "callingUser" : {
+ "LastName" : "elven princess",
+ "UserID" : "mockUserId2",
+ "FirstName" : "Your favorite"
+ },
+ "otherUser" : {
+ "LastName" : "User",
+ "UserName" : "deleteduser",
+ "FirstName" : "Unknown",
+ "UserID" : "deleted",
+ "PhoneNo" : "deleted"
+ },
+ "deviceType" : "lock",
+ "deviceName" : "MockHouseTDoor",
+ "action" : "lock",
+ "dateTime" : 1582007218000,
+ "info" : {
+ "remote" : false,
+ "DateLogActionID" : "ABC+Time"
+ },
+ "deviceID" : "online_with_doorsense",
+ "house" : {
+ "houseName" : "MockHouse",
+ "houseID" : "123"
+ }
+}]
diff --git a/tests/fixtures/august/get_activity.lock_from_keypad.json b/tests/fixtures/august/get_activity.lock_from_keypad.json
new file mode 100644
index 00000000000..4c76fc46cd8
--- /dev/null
+++ b/tests/fixtures/august/get_activity.lock_from_keypad.json
@@ -0,0 +1,35 @@
+[{
+ "entities" : {
+ "activity" : "mockActivity2",
+ "house" : "123",
+ "device" : "online_with_doorsense",
+ "callingUser" : "mockUserId2",
+ "otherUser" : "deleted"
+ },
+ "callingUser" : {
+ "LastName" : "elven princess",
+ "UserID" : "mockUserId2",
+ "FirstName" : "Your favorite"
+ },
+ "otherUser" : {
+ "LastName" : "User",
+ "UserName" : "deleteduser",
+ "FirstName" : "Unknown",
+ "UserID" : "deleted",
+ "PhoneNo" : "deleted"
+ },
+ "deviceType" : "lock",
+ "deviceName" : "MockHouseTDoor",
+ "action" : "lock",
+ "dateTime" : 1582007218000,
+ "info" : {
+ "remote" : false,
+ "keypad" : true,
+ "DateLogActionID" : "ABC+Time"
+ },
+ "deviceID" : "online_with_doorsense",
+ "house" : {
+ "houseName" : "MockHouse",
+ "houseID" : "123"
+ }
+}]
diff --git a/tests/fixtures/august/get_doorbell.json b/tests/fixtures/august/get_doorbell.json
new file mode 100644
index 00000000000..abe6e37b1e3
--- /dev/null
+++ b/tests/fixtures/august/get_doorbell.json
@@ -0,0 +1,83 @@
+{
+ "status_timestamp" : 1512811834532,
+ "appID" : "august-iphone",
+ "LockID" : "BBBB1F5F11114C24CCCC97571DD6AAAA",
+ "recentImage" : {
+ "original_filename" : "file",
+ "placeholder" : false,
+ "bytes" : 24476,
+ "height" : 640,
+ "format" : "jpg",
+ "width" : 480,
+ "version" : 1512892814,
+ "resource_type" : "image",
+ "etag" : "54966926be2e93f77d498a55f247661f",
+ "tags" : [],
+ "public_id" : "qqqqt4ctmxwsysylaaaa",
+ "url" : "http://image.com/vmk16naaaa7ibuey7sar.jpg",
+ "created_at" : "2017-12-10T08:01:35Z",
+ "signature" : "75z47ca21b5e8ffda21d2134e478a2307c4625da",
+ "secure_url" : "https://image.com/vmk16naaaa7ibuey7sar.jpg",
+ "type" : "upload"
+ },
+ "settings" : {
+ "keepEncoderRunning" : true,
+ "videoResolution" : "640x480",
+ "minACNoScaling" : 40,
+ "irConfiguration" : 8448272,
+ "directLink" : true,
+ "overlayEnabled" : true,
+ "notify_when_offline" : true,
+ "micVolume" : 100,
+ "bitrateCeiling" : 512000,
+ "initialBitrate" : 384000,
+ "IVAEnabled" : false,
+ "turnOffCamera" : false,
+ "ringSoundEnabled" : true,
+ "JPGQuality" : 70,
+ "motion_notifications" : true,
+ "speakerVolume" : 92,
+ "buttonpush_notifications" : true,
+ "ABREnabled" : true,
+ "debug" : false,
+ "batteryLowThreshold" : 3.1,
+ "batteryRun" : false,
+ "IREnabled" : true,
+ "batteryUseThreshold" : 3.4
+ },
+ "doorbellServerURL" : "https://doorbells.august.com",
+ "name" : "Front Door",
+ "createdAt" : "2016-11-26T22:27:11.176Z",
+ "installDate" : "2016-11-26T22:27:11.176Z",
+ "serialNumber" : "tBXZR0Z35E",
+ "dvrSubscriptionSetupDone" : true,
+ "caps" : [
+ "reconnect"
+ ],
+ "doorbellID" : "K98GiDT45GUL",
+ "HouseID" : "3dd2accaea08",
+ "telemetry" : {
+ "signal_level" : -56,
+ "date" : "2017-12-10 08:05:12",
+ "battery_soc" : 96,
+ "battery" : 4.061763,
+ "steady_ac_in" : 22.196405,
+ "BSSID" : "88:ee:00:dd:aa:11",
+ "SSID" : "foo_ssid",
+ "updated_at" : "2017-12-10T08:05:13.650Z",
+ "temperature" : 28.25,
+ "wifi_freq" : 5745,
+ "load_average" : "0.50 0.47 0.35 1/154 9345",
+ "link_quality" : 54,
+ "battery_soh" : 95,
+ "uptime" : "16168.75 13830.49",
+ "ip_addr" : "10.0.1.11",
+ "doorbell_low_battery" : false,
+ "ac_in" : 23.856874
+ },
+ "installUserID" : "c3b2a94e-373e-aaaa-bbbb-36e996827777",
+ "status" : "doorbell_call_status_online",
+ "firmwareVersion" : "2.3.0-RC153+201711151527",
+ "pubsubChannel" : "7c7a6672-59c8-3333-ffff-dcd98705cccc",
+ "updatedAt" : "2017-12-10T08:05:13.650Z"
+}
diff --git a/tests/fixtures/august/get_doorbell.nobattery.json b/tests/fixtures/august/get_doorbell.nobattery.json
new file mode 100644
index 00000000000..e2a93a086cc
--- /dev/null
+++ b/tests/fixtures/august/get_doorbell.nobattery.json
@@ -0,0 +1,80 @@
+{
+ "status_timestamp" : 1512811834532,
+ "appID" : "august-iphone",
+ "LockID" : "BBBB1F5F11114C24CCCC97571DD6AAAA",
+ "recentImage" : {
+ "original_filename" : "file",
+ "placeholder" : false,
+ "bytes" : 24476,
+ "height" : 640,
+ "format" : "jpg",
+ "width" : 480,
+ "version" : 1512892814,
+ "resource_type" : "image",
+ "etag" : "54966926be2e93f77d498a55f247661f",
+ "tags" : [],
+ "public_id" : "qqqqt4ctmxwsysylaaaa",
+ "url" : "http://image.com/vmk16naaaa7ibuey7sar.jpg",
+ "created_at" : "2017-12-10T08:01:35Z",
+ "signature" : "75z47ca21b5e8ffda21d2134e478a2307c4625da",
+ "secure_url" : "https://image.com/vmk16naaaa7ibuey7sar.jpg",
+ "type" : "upload"
+ },
+ "settings" : {
+ "keepEncoderRunning" : true,
+ "videoResolution" : "640x480",
+ "minACNoScaling" : 40,
+ "irConfiguration" : 8448272,
+ "directLink" : true,
+ "overlayEnabled" : true,
+ "notify_when_offline" : true,
+ "micVolume" : 100,
+ "bitrateCeiling" : 512000,
+ "initialBitrate" : 384000,
+ "IVAEnabled" : false,
+ "turnOffCamera" : false,
+ "ringSoundEnabled" : true,
+ "JPGQuality" : 70,
+ "motion_notifications" : true,
+ "speakerVolume" : 92,
+ "buttonpush_notifications" : true,
+ "ABREnabled" : true,
+ "debug" : false,
+ "batteryLowThreshold" : 3.1,
+ "batteryRun" : false,
+ "IREnabled" : true,
+ "batteryUseThreshold" : 3.4
+ },
+ "doorbellServerURL" : "https://doorbells.august.com",
+ "name" : "Front Door",
+ "createdAt" : "2016-11-26T22:27:11.176Z",
+ "installDate" : "2016-11-26T22:27:11.176Z",
+ "serialNumber" : "tBXZR0Z35E",
+ "dvrSubscriptionSetupDone" : true,
+ "caps" : [
+ "reconnect"
+ ],
+ "doorbellID" : "K98GiDT45GUL",
+ "HouseID" : "3dd2accaea08",
+ "telemetry" : {
+ "signal_level" : -56,
+ "date" : "2017-12-10 08:05:12",
+ "steady_ac_in" : 22.196405,
+ "BSSID" : "88:ee:00:dd:aa:11",
+ "SSID" : "foo_ssid",
+ "updated_at" : "2017-12-10T08:05:13.650Z",
+ "temperature" : 28.25,
+ "wifi_freq" : 5745,
+ "load_average" : "0.50 0.47 0.35 1/154 9345",
+ "link_quality" : 54,
+ "uptime" : "16168.75 13830.49",
+ "ip_addr" : "10.0.1.11",
+ "doorbell_low_battery" : false,
+ "ac_in" : 23.856874
+ },
+ "installUserID" : "c3b2a94e-373e-aaaa-bbbb-36e996827777",
+ "status" : "doorbell_call_status_online",
+ "firmwareVersion" : "2.3.0-RC153+201711151527",
+ "pubsubChannel" : "7c7a6672-59c8-3333-ffff-dcd98705cccc",
+ "updatedAt" : "2017-12-10T08:05:13.650Z"
+}
diff --git a/tests/fixtures/august/get_doorbell.offline.json b/tests/fixtures/august/get_doorbell.offline.json
new file mode 100644
index 00000000000..dec94374355
--- /dev/null
+++ b/tests/fixtures/august/get_doorbell.offline.json
@@ -0,0 +1,130 @@
+{
+ "recentImage" : {
+ "tags" : [],
+ "height" : 576,
+ "public_id" : "fdsfds",
+ "bytes" : 50013,
+ "resource_type" : "image",
+ "original_filename" : "file",
+ "version" : 1582242766,
+ "format" : "jpg",
+ "signature" : "fdsfdsf",
+ "created_at" : "2020-02-20T23:52:46Z",
+ "type" : "upload",
+ "placeholder" : false,
+ "url" : "http://res.cloudinary.com/august-com/image/upload/ccc/ccccc.jpg",
+ "secure_url" : "https://res.cloudinary.com/august-com/image/upload/cc/cccc.jpg",
+ "etag" : "zds",
+ "width" : 720
+ },
+ "firmwareVersion" : "3.1.0-HYDRC75+201909251139",
+ "doorbellServerURL" : "https://doorbells.august.com",
+ "installUserID" : "mock",
+ "caps" : [
+ "reconnect",
+ "webrtc",
+ "tcp_wakeup"
+ ],
+ "messagingProtocol" : "pubnub",
+ "createdAt" : "2020-02-12T03:52:28.719Z",
+ "invitations" : [],
+ "appID" : "august-iphone-v5",
+ "HouseID" : "houseid1",
+ "doorbellID" : "tmt100",
+ "name" : "Front Door",
+ "settings" : {
+ "batteryUseThreshold" : 3.4,
+ "brightness" : 50,
+ "batteryChargeCurrent" : 60,
+ "overCurrentThreshold" : -250,
+ "irLedBrightness" : 40,
+ "videoResolution" : "720x576",
+ "pirPulseCounter" : 1,
+ "contrast" : 50,
+ "micVolume" : 50,
+ "directLink" : true,
+ "auto_contrast_mode" : 0,
+ "saturation" : 50,
+ "motion_notifications" : true,
+ "pirSensitivity" : 20,
+ "pirBlindTime" : 7,
+ "notify_when_offline" : false,
+ "nightModeAlsThreshold" : 10,
+ "minACNoScaling" : 40,
+ "DVRRecordingTimeout" : 15,
+ "turnOffCamera" : false,
+ "debug" : false,
+ "keepEncoderRunning" : true,
+ "pirWindowTime" : 0,
+ "bitrateCeiling" : 2000000,
+ "backlight_comp" : false,
+ "buttonpush_notifications" : true,
+ "buttonpush_notifications_partners" : false,
+ "minimumSnapshotInterval" : 30,
+ "pirConfiguration" : 272,
+ "batteryLowThreshold" : 3.1,
+ "sharpness" : 50,
+ "ABREnabled" : true,
+ "hue" : 50,
+ "initialBitrate" : 1000000,
+ "ringSoundEnabled" : true,
+ "IVAEnabled" : false,
+ "overlayEnabled" : true,
+ "speakerVolume" : 92,
+ "ringRepetitions" : 3,
+ "powerProfilePreset" : -1,
+ "irConfiguration" : 16836880,
+ "JPGQuality" : 70,
+ "IREnabled" : true
+ },
+ "updatedAt" : "2020-02-20T23:58:21.580Z",
+ "serialNumber" : "abc",
+ "installDate" : "2019-02-12T03:52:28.719Z",
+ "dvrSubscriptionSetupDone" : true,
+ "pubsubChannel" : "mock",
+ "chimes" : [
+ {
+ "updatedAt" : "2020-02-12T03:55:38.805Z",
+ "_id" : "cccc",
+ "type" : 1,
+ "serialNumber" : "ccccc",
+ "doorbellID" : "tmt100",
+ "name" : "Living Room",
+ "chimeID" : "cccc",
+ "createdAt" : "2020-02-12T03:55:38.805Z",
+ "firmware" : "3.1.16"
+ }
+ ],
+ "telemetry" : {
+ "battery" : 3.985,
+ "battery_soc" : 81,
+ "load_average" : "0.45 0.18 0.07 4/98 831",
+ "ip_addr" : "192.168.100.174",
+ "BSSID" : "snp",
+ "uptime" : "96.55 70.59",
+ "SSID" : "bob",
+ "updated_at" : "2020-02-20T23:53:09.586Z",
+ "dtim_period" : 0,
+ "wifi_freq" : 2462,
+ "date" : "2020-02-20 11:47:36",
+ "BSSIDManufacturer" : "Ubiquiti - Ubiquiti Networks Inc.",
+ "battery_temp" : 22,
+ "battery_avg_cur" : -291,
+ "beacon_interval" : 0,
+ "signal_level" : -49,
+ "battery_soh" : 95,
+ "doorbell_low_battery" : false
+ },
+ "secChipCertSerial" : "",
+ "tcpKeepAlive" : {
+ "keepAliveUUID" : "mock",
+ "wakeUp" : {
+ "token" : "wakemeup",
+ "lastUpdated" : 1582242723931
+ }
+ },
+ "statusUpdatedAtMs" : 1582243101579,
+ "status" : "doorbell_offline",
+ "type" : "hydra1",
+ "HouseName" : "housename"
+}
diff --git a/tests/fixtures/august/get_lock.doorsense_init.json b/tests/fixtures/august/get_lock.doorsense_init.json
new file mode 100644
index 00000000000..be60bbe6236
--- /dev/null
+++ b/tests/fixtures/august/get_lock.doorsense_init.json
@@ -0,0 +1,103 @@
+{
+ "LockName": "Front Door Lock",
+ "Type": 2,
+ "Created": "2017-12-10T03:12:09.210Z",
+ "Updated": "2017-12-10T03:12:09.210Z",
+ "LockID": "A6697750D607098BAE8D6BAA11EF8063",
+ "HouseID": "000000000000",
+ "HouseName": "My House",
+ "Calibrated": false,
+ "skuNumber": "AUG-SL02-M02-S02",
+ "timeZone": "America/Vancouver",
+ "battery": 0.88,
+ "SerialNumber": "X2FSW05DGA",
+ "LockStatus": {
+ "status": "locked",
+ "doorState": "init",
+ "dateTime": "2017-12-10T04:48:30.272Z",
+ "isLockStatusChanged": false,
+ "valid": true
+ },
+ "currentFirmwareVersion": "109717e9-3.0.44-3.0.30",
+ "homeKitEnabled": false,
+ "zWaveEnabled": false,
+ "isGalileo": false,
+ "Bridge": {
+ "_id": "aaacab87f7efxa0015884999",
+ "mfgBridgeID": "AAGPP102XX",
+ "deviceModel": "august-doorbell",
+ "firmwareVersion": "2.3.0-RC153+201711151527",
+ "operative": true
+ },
+ "keypad": {
+ "_id": "5bc65c24e6ef2a263e1450a8",
+ "serialNumber": "K1GXB0054Z",
+ "lockID": "92412D1B44004595B5DEB134E151A8D3",
+ "currentFirmwareVersion": "2.27.0",
+ "battery": {},
+ "batteryLevel": "Medium",
+ "batteryRaw": 170
+ },
+ "OfflineKeys": {
+ "created": [],
+ "loaded": [
+ {
+ "UserID": "cccca94e-373e-aaaa-bbbb-333396827777",
+ "slot": 1,
+ "key": "kkk01d4300c1dcxxx1c330f794941111",
+ "created": "2017-12-10T03:12:09.215Z",
+ "loaded": "2017-12-10T03:12:54.391Z"
+ }
+ ],
+ "deleted": [],
+ "loadedhk": [
+ {
+ "key": "kkk01d4300c1dcxxx1c330f794941222",
+ "slot": 256,
+ "UserID": "cccca94e-373e-aaaa-bbbb-333396827777",
+ "created": "2017-12-10T03:12:09.218Z",
+ "loaded": "2017-12-10T03:12:55.563Z"
+ }
+ ]
+ },
+ "parametersToSet": {},
+ "users": {
+ "cccca94e-373e-aaaa-bbbb-333396827777": {
+ "UserType": "superuser",
+ "FirstName": "Foo",
+ "LastName": "Bar",
+ "identifiers": [
+ "email:foo@bar.com",
+ "phone:+177777777777"
+ ],
+ "imageInfo": {
+ "original": {
+ "width": 948,
+ "height": 949,
+ "format": "jpg",
+ "url": "http://www.image.com/foo.jpeg",
+ "secure_url": "https://www.image.com/foo.jpeg"
+ },
+ "thumbnail": {
+ "width": 128,
+ "height": 128,
+ "format": "jpg",
+ "url": "http://www.image.com/foo.jpeg",
+ "secure_url": "https://www.image.com/foo.jpeg"
+ }
+ }
+ }
+ },
+ "pubsubChannel": "3333a674-ffff-aaaa-b351-b3a4473f3333",
+ "ruleHash": {},
+ "cameras": [],
+ "geofenceLimits": {
+ "ios": {
+ "debounceInterval": 90,
+ "gpsAccuracyMultiplier": 2.5,
+ "maximumGeofence": 5000,
+ "minimumGeofence": 100,
+ "minGPSAccuracyRequired": 80
+ }
+ }
+}
diff --git a/tests/fixtures/august/get_lock.low_keypad_battery.json b/tests/fixtures/august/get_lock.low_keypad_battery.json
new file mode 100644
index 00000000000..f848a8d30eb
--- /dev/null
+++ b/tests/fixtures/august/get_lock.low_keypad_battery.json
@@ -0,0 +1,103 @@
+{
+ "LockName": "Front Door Lock",
+ "Type": 2,
+ "Created": "2017-12-10T03:12:09.210Z",
+ "Updated": "2017-12-10T03:12:09.210Z",
+ "LockID": "A6697750D607098BAE8D6BAA11EF8063",
+ "HouseID": "000000000000",
+ "HouseName": "My House",
+ "Calibrated": false,
+ "skuNumber": "AUG-SL02-M02-S02",
+ "timeZone": "America/Vancouver",
+ "battery": 0.88,
+ "SerialNumber": "X2FSW05DGA",
+ "LockStatus": {
+ "status": "locked",
+ "doorState": "closed",
+ "dateTime": "2017-12-10T04:48:30.272Z",
+ "isLockStatusChanged": true,
+ "valid": true
+ },
+ "currentFirmwareVersion": "109717e9-3.0.44-3.0.30",
+ "homeKitEnabled": false,
+ "zWaveEnabled": false,
+ "isGalileo": false,
+ "Bridge": {
+ "_id": "aaacab87f7efxa0015884999",
+ "mfgBridgeID": "AAGPP102XX",
+ "deviceModel": "august-doorbell",
+ "firmwareVersion": "2.3.0-RC153+201711151527",
+ "operative": true
+ },
+ "keypad": {
+ "_id": "5bc65c24e6ef2a263e1450a8",
+ "serialNumber": "K1GXB0054Z",
+ "lockID": "92412D1B44004595B5DEB134E151A8D3",
+ "currentFirmwareVersion": "2.27.0",
+ "battery": {},
+ "batteryLevel": "Low",
+ "batteryRaw": 170
+ },
+ "OfflineKeys": {
+ "created": [],
+ "loaded": [
+ {
+ "UserID": "cccca94e-373e-aaaa-bbbb-333396827777",
+ "slot": 1,
+ "key": "kkk01d4300c1dcxxx1c330f794941111",
+ "created": "2017-12-10T03:12:09.215Z",
+ "loaded": "2017-12-10T03:12:54.391Z"
+ }
+ ],
+ "deleted": [],
+ "loadedhk": [
+ {
+ "key": "kkk01d4300c1dcxxx1c330f794941222",
+ "slot": 256,
+ "UserID": "cccca94e-373e-aaaa-bbbb-333396827777",
+ "created": "2017-12-10T03:12:09.218Z",
+ "loaded": "2017-12-10T03:12:55.563Z"
+ }
+ ]
+ },
+ "parametersToSet": {},
+ "users": {
+ "cccca94e-373e-aaaa-bbbb-333396827777": {
+ "UserType": "superuser",
+ "FirstName": "Foo",
+ "LastName": "Bar",
+ "identifiers": [
+ "email:foo@bar.com",
+ "phone:+177777777777"
+ ],
+ "imageInfo": {
+ "original": {
+ "width": 948,
+ "height": 949,
+ "format": "jpg",
+ "url": "http://www.image.com/foo.jpeg",
+ "secure_url": "https://www.image.com/foo.jpeg"
+ },
+ "thumbnail": {
+ "width": 128,
+ "height": 128,
+ "format": "jpg",
+ "url": "http://www.image.com/foo.jpeg",
+ "secure_url": "https://www.image.com/foo.jpeg"
+ }
+ }
+ }
+ },
+ "pubsubChannel": "3333a674-ffff-aaaa-b351-b3a4473f3333",
+ "ruleHash": {},
+ "cameras": [],
+ "geofenceLimits": {
+ "ios": {
+ "debounceInterval": 90,
+ "gpsAccuracyMultiplier": 2.5,
+ "maximumGeofence": 5000,
+ "minimumGeofence": 100,
+ "minGPSAccuracyRequired": 80
+ }
+ }
+}
diff --git a/tests/fixtures/august/get_lock.offline.json b/tests/fixtures/august/get_lock.offline.json
new file mode 100644
index 00000000000..502a78674e9
--- /dev/null
+++ b/tests/fixtures/august/get_lock.offline.json
@@ -0,0 +1,68 @@
+{
+ "Calibrated" : false,
+ "Created" : "2000-00-00T00:00:00.447Z",
+ "HouseID" : "houseid",
+ "HouseName" : "MockName",
+ "LockID" : "ABC",
+ "LockName" : "Test",
+ "LockStatus" : {
+ "status" : "unknown"
+ },
+ "OfflineKeys" : {
+ "created" : [],
+ "createdhk" : [
+ {
+ "UserID" : "mock-user-id",
+ "created" : "2000-00-00T00:00:00.447Z",
+ "key" : "mockkey",
+ "slot" : 12
+ }
+ ],
+ "deleted" : [],
+ "loaded" : [
+ {
+ "UserID" : "userid",
+ "created" : "2000-00-00T00:00:00.447Z",
+ "key" : "key",
+ "loaded" : "2000-00-00T00:00:00.447Z",
+ "slot" : 1
+ }
+ ]
+ },
+ "SerialNumber" : "ABC",
+ "Type" : 3,
+ "Updated" : "2000-00-00T00:00:00.447Z",
+ "battery" : -1,
+ "cameras" : [],
+ "currentFirmwareVersion" : "undefined-1.59.0-1.13.2",
+ "geofenceLimits" : {
+ "ios" : {
+ "debounceInterval" : 90,
+ "gpsAccuracyMultiplier" : 2.5,
+ "maximumGeofence" : 5000,
+ "minGPSAccuracyRequired" : 80,
+ "minimumGeofence" : 100
+ }
+ },
+ "homeKitEnabled" : false,
+ "isGalileo" : false,
+ "macAddress" : "a:b:c",
+ "parametersToSet" : {},
+ "pubsubChannel" : "mockpubsub",
+ "ruleHash" : {},
+ "skuNumber" : "AUG-X",
+ "supportsEntryCodes" : false,
+ "users" : {
+ "mockuserid" : {
+ "FirstName" : "MockName",
+ "LastName" : "House",
+ "UserType" : "superuser",
+ "identifiers" : [
+ "phone:+15558675309",
+ "email:mockme@mock.org"
+ ]
+ }
+ },
+ "zWaveDSK" : "1-2-3-4",
+ "zWaveEnabled" : true
+}
diff --git a/tests/fixtures/august/get_lock.online.json b/tests/fixtures/august/get_lock.online.json
new file mode 100644
index 00000000000..8003359e589
--- /dev/null
+++ b/tests/fixtures/august/get_lock.online.json
@@ -0,0 +1,103 @@
+{
+ "LockName": "Front Door Lock",
+ "Type": 2,
+ "Created": "2017-12-10T03:12:09.210Z",
+ "Updated": "2017-12-10T03:12:09.210Z",
+ "LockID": "A6697750D607098BAE8D6BAA11EF8063",
+ "HouseID": "000000000000",
+ "HouseName": "My House",
+ "Calibrated": false,
+ "skuNumber": "AUG-SL02-M02-S02",
+ "timeZone": "America/Vancouver",
+ "battery": 0.88,
+ "SerialNumber": "X2FSW05DGA",
+ "LockStatus": {
+ "status": "locked",
+ "doorState": "closed",
+ "dateTime": "2017-12-10T04:48:30.272Z",
+ "isLockStatusChanged": true,
+ "valid": true
+ },
+ "currentFirmwareVersion": "109717e9-3.0.44-3.0.30",
+ "homeKitEnabled": false,
+ "zWaveEnabled": false,
+ "isGalileo": false,
+ "Bridge": {
+ "_id": "aaacab87f7efxa0015884999",
+ "mfgBridgeID": "AAGPP102XX",
+ "deviceModel": "august-doorbell",
+ "firmwareVersion": "2.3.0-RC153+201711151527",
+ "operative": true
+ },
+ "keypad": {
+ "_id": "5bc65c24e6ef2a263e1450a8",
+ "serialNumber": "K1GXB0054Z",
+ "lockID": "92412D1B44004595B5DEB134E151A8D3",
+ "currentFirmwareVersion": "2.27.0",
+ "battery": {},
+ "batteryLevel": "Medium",
+ "batteryRaw": 170
+ },
+ "OfflineKeys": {
+ "created": [],
+ "loaded": [
+ {
+ "UserID": "cccca94e-373e-aaaa-bbbb-333396827777",
+ "slot": 1,
+ "key": "kkk01d4300c1dcxxx1c330f794941111",
+ "created": "2017-12-10T03:12:09.215Z",
+ "loaded": "2017-12-10T03:12:54.391Z"
+ }
+ ],
+ "deleted": [],
+ "loadedhk": [
+ {
+ "key": "kkk01d4300c1dcxxx1c330f794941222",
+ "slot": 256,
+ "UserID": "cccca94e-373e-aaaa-bbbb-333396827777",
+ "created": "2017-12-10T03:12:09.218Z",
+ "loaded": "2017-12-10T03:12:55.563Z"
+ }
+ ]
+ },
+ "parametersToSet": {},
+ "users": {
+ "cccca94e-373e-aaaa-bbbb-333396827777": {
+ "UserType": "superuser",
+ "FirstName": "Foo",
+ "LastName": "Bar",
+ "identifiers": [
+ "email:foo@bar.com",
+ "phone:+177777777777"
+ ],
+ "imageInfo": {
+ "original": {
+ "width": 948,
+ "height": 949,
+ "format": "jpg",
+ "url": "http://www.image.com/foo.jpeg",
+ "secure_url": "https://www.image.com/foo.jpeg"
+ },
+ "thumbnail": {
+ "width": 128,
+ "height": 128,
+ "format": "jpg",
+ "url": "http://www.image.com/foo.jpeg",
+ "secure_url": "https://www.image.com/foo.jpeg"
+ }
+ }
+ }
+ },
+ "pubsubChannel": "3333a674-ffff-aaaa-b351-b3a4473f3333",
+ "ruleHash": {},
+ "cameras": [],
+ "geofenceLimits": {
+ "ios": {
+ "debounceInterval": 90,
+ "gpsAccuracyMultiplier": 2.5,
+ "maximumGeofence": 5000,
+ "minimumGeofence": 100,
+ "minGPSAccuracyRequired": 80
+ }
+ }
+}
diff --git a/tests/fixtures/august/get_lock.online.unknown_state.json b/tests/fixtures/august/get_lock.online.unknown_state.json
new file mode 100644
index 00000000000..ad455655902
--- /dev/null
+++ b/tests/fixtures/august/get_lock.online.unknown_state.json
@@ -0,0 +1,59 @@
+{
+ "LockName": "Side Door",
+ "Type": 1001,
+ "Created": "2019-10-07T01:49:06.831Z",
+ "Updated": "2019-10-07T01:49:06.831Z",
+ "LockID": "BROKENID",
+ "HouseID": "abc",
+ "HouseName": "dog",
+ "Calibrated": false,
+ "timeZone": "America/Chicago",
+ "battery": 0.9524716174964851,
+ "hostLockInfo": {
+ "serialNumber": "YR",
+ "manufacturer": "yale",
+ "productID": 1536,
+ "productTypeID": 32770
+ },
+ "supportsEntryCodes": true,
+ "skuNumber": "AUG-MD01",
+ "macAddress": "MAC",
+ "SerialNumber": "M1FXZ00EZ9",
+ "LockStatus": {
+ "status": "unknown_error_during_connect",
+ "dateTime": "2020-02-22T02:48:11.741Z",
+ "isLockStatusChanged": true,
+ "valid": true,
+ "doorState": "closed"
+ },
+ "currentFirmwareVersion": "undefined-4.3.0-1.8.14",
+ "homeKitEnabled": true,
+ "zWaveEnabled": false,
+ "isGalileo": false,
+ "Bridge": {
+ "_id": "id",
+ "mfgBridgeID": "id",
+ "deviceModel": "august-connect",
+ "firmwareVersion": "2.2.1",
+ "operative": true,
+ "status": {
+ "current": "online",
+ "updated": "2020-02-21T15:06:47.001Z",
+ "lastOnline": "2020-02-21T15:06:47.001Z",
+ "lastOffline": "2020-02-06T17:33:21.265Z"
+ },
+ "hyperBridge": true
+ },
+ "parametersToSet": {},
+ "ruleHash": {},
+ "cameras": [],
+ "geofenceLimits": {
+ "ios": {
+ "debounceInterval": 90,
+ "gpsAccuracyMultiplier": 2.5,
+ "maximumGeofence": 5000,
+ "minimumGeofence": 100,
+ "minGPSAccuracyRequired": 80
+ }
+ }
+}
diff --git a/tests/fixtures/august/get_lock.online_missing_doorsense.json b/tests/fixtures/august/get_lock.online_missing_doorsense.json
new file mode 100644
index 00000000000..46971c3bbd2
--- /dev/null
+++ b/tests/fixtures/august/get_lock.online_missing_doorsense.json
@@ -0,0 +1,50 @@
+{
+ "Bridge" : {
+ "_id" : "bridgeid",
+ "deviceModel" : "august-connect",
+ "firmwareVersion" : "2.2.1",
+ "hyperBridge" : true,
+ "mfgBridgeID" : "C5WY200WSH",
+ "operative" : true,
+ "status" : {
+ "current" : "online",
+ "lastOffline" : "2000-00-00T00:00:00.447Z",
+ "lastOnline" : "2000-00-00T00:00:00.447Z",
+ "updated" : "2000-00-00T00:00:00.447Z"
+ }
+ },
+ "Calibrated" : false,
+ "Created" : "2000-00-00T00:00:00.447Z",
+ "HouseID" : "123",
+ "HouseName" : "Test",
+ "LockID" : "missing_doorsense_id",
+ "LockName" : "Online door missing doorsense",
+ "LockStatus" : {
+ "dateTime" : "2017-12-10T04:48:30.272Z",
+ "isLockStatusChanged" : false,
+ "status" : "locked",
+ "valid" : true
+ },
+ "SerialNumber" : "XY",
+ "Type" : 1001,
+ "Updated" : "2000-00-00T00:00:00.447Z",
+ "battery" : 0.922,
+ "currentFirmwareVersion" : "undefined-4.3.0-1.8.14",
+ "homeKitEnabled" : true,
+ "hostLockInfo" : {
+ "manufacturer" : "yale",
+ "productID" : 1536,
+ "productTypeID" : 32770,
+ "serialNumber" : "ABC"
+ },
+ "isGalileo" : false,
+ "macAddress" : "12:22",
+ "pins" : {
+ "created" : [],
+ "loaded" : []
+ },
+ "skuNumber" : "AUG-MD01",
+ "supportsEntryCodes" : true,
+ "timeZone" : "Pacific/Hawaii",
+ "zWaveEnabled" : false
+}
diff --git a/tests/fixtures/august/get_lock.online_with_doorsense.json b/tests/fixtures/august/get_lock.online_with_doorsense.json
new file mode 100644
index 00000000000..f7376570482
--- /dev/null
+++ b/tests/fixtures/august/get_lock.online_with_doorsense.json
@@ -0,0 +1,51 @@
+{
+ "Bridge" : {
+ "_id" : "bridgeid",
+ "deviceModel" : "august-connect",
+ "firmwareVersion" : "2.2.1",
+ "hyperBridge" : true,
+ "mfgBridgeID" : "C5WY200WSH",
+ "operative" : true,
+ "status" : {
+ "current" : "online",
+ "lastOffline" : "2000-00-00T00:00:00.447Z",
+ "lastOnline" : "2000-00-00T00:00:00.447Z",
+ "updated" : "2000-00-00T00:00:00.447Z"
+ }
+ },
+ "Calibrated" : false,
+ "Created" : "2000-00-00T00:00:00.447Z",
+ "HouseID" : "123",
+ "HouseName" : "Test",
+ "LockID" : "online_with_doorsense",
+ "LockName" : "Online door with doorsense",
+ "LockStatus" : {
+ "dateTime" : "2017-12-10T04:48:30.272Z",
+ "doorState" : "open",
+ "isLockStatusChanged" : false,
+ "status" : "locked",
+ "valid" : true
+ },
+ "SerialNumber" : "XY",
+ "Type" : 1001,
+ "Updated" : "2000-00-00T00:00:00.447Z",
+ "battery" : 0.922,
+ "currentFirmwareVersion" : "undefined-4.3.0-1.8.14",
+ "homeKitEnabled" : true,
+ "hostLockInfo" : {
+ "manufacturer" : "yale",
+ "productID" : 1536,
+ "productTypeID" : 32770,
+ "serialNumber" : "ABC"
+ },
+ "isGalileo" : false,
+ "macAddress" : "12:22",
+ "pins" : {
+ "created" : [],
+ "loaded" : []
+ },
+ "skuNumber" : "AUG-MD01",
+ "supportsEntryCodes" : true,
+ "timeZone" : "Pacific/Hawaii",
+ "zWaveEnabled" : false
+}
diff --git a/tests/fixtures/august/get_locks.json b/tests/fixtures/august/get_locks.json
new file mode 100644
index 00000000000..3fab55f82c9
--- /dev/null
+++ b/tests/fixtures/august/get_locks.json
@@ -0,0 +1,16 @@
+{
+ "A6697750D607098BAE8D6BAA11EF8063": {
+ "LockName": "Front Door Lock",
+ "UserType": "superuser",
+ "macAddress": "2E:BA:C4:14:3F:09",
+ "HouseID": "000000000000",
+ "HouseName": "A House"
+ },
+ "A6697750D607098BAE8D6BAA11EF9999": {
+ "LockName": "Back Door Lock",
+ "UserType": "user",
+ "macAddress": "2E:BA:C4:14:3F:88",
+ "HouseID": "000000000011",
+ "HouseName": "A House"
+ }
+}
diff --git a/tests/fixtures/august/lock_open.json b/tests/fixtures/august/lock_open.json
new file mode 100644
index 00000000000..67e3ccfbf15
--- /dev/null
+++ b/tests/fixtures/august/lock_open.json
@@ -0,0 +1,26 @@
+{
+ "status" : "kAugLockState_Locked",
+ "resultsFromOperationCache" : false,
+ "retryCount" : 1,
+ "info" : {
+ "wlanRSSI" : -54,
+ "lockType" : "lock_version_1001",
+ "lockStatusChanged" : false,
+ "serialNumber" : "ABC",
+ "serial" : "123",
+ "action" : "lock",
+ "context" : {
+ "startDate" : "2020-02-19T01:59:39.516Z",
+ "retryCount" : 1,
+ "transactionID" : "mock"
+ },
+ "bridgeID" : "mock",
+ "wlanSNR" : 41,
+ "startTime" : "2020-02-19T01:59:39.517Z",
+ "duration" : 5149,
+ "lockID" : "ABC",
+ "rssi" : -77
+ },
+ "totalTime" : 5162,
+ "doorState" : "kAugDoorState_Open"
+}
diff --git a/tests/fixtures/august/unlock_closed.json b/tests/fixtures/august/unlock_closed.json
new file mode 100644
index 00000000000..57b712f55e1
--- /dev/null
+++ b/tests/fixtures/august/unlock_closed.json
@@ -0,0 +1,26 @@
+{
+ "status" : "kAugLockState_Unlocked",
+ "resultsFromOperationCache" : false,
+ "retryCount" : 1,
+ "info" : {
+ "wlanRSSI" : -54,
+ "lockType" : "lock_version_1001",
+ "lockStatusChanged" : false,
+ "serialNumber" : "ABC",
+ "serial" : "123",
+ "action" : "lock",
+ "context" : {
+ "startDate" : "2020-02-19T01:59:39.516Z",
+ "retryCount" : 1,
+ "transactionID" : "mock"
+ },
+ "bridgeID" : "mock",
+ "wlanSNR" : 41,
+ "startTime" : "2020-02-19T01:59:39.517Z",
+ "duration" : 5149,
+ "lockID" : "ABC",
+ "rssi" : -77
+ },
+ "totalTime" : 5162,
+ "doorState" : "kAugDoorState_Closed"
+}
diff --git a/tests/fixtures/brother_printer_data.json b/tests/fixtures/brother_printer_data.json
index 977953dd3bd..d1b631d7548 100644
--- a/tests/fixtures/brother_printer_data.json
+++ b/tests/fixtures/brother_printer_data.json
@@ -10,7 +10,7 @@
"81010400000050",
"8601040000000a"
],
- "1.3.6.1.4.1.2435.2.4.3.2435.5.13.3.0": "Brother HL-L2340DW",
+ "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.1.0": "0123456789",
"1.3.6.1.4.1.2435.2.3.9.4.2.1.5.4.5.2.0": "WAITING "
diff --git a/tests/fixtures/griddy/getnow.json b/tests/fixtures/griddy/getnow.json
new file mode 100644
index 00000000000..2bf685dac44
--- /dev/null
+++ b/tests/fixtures/griddy/getnow.json
@@ -0,0 +1,600 @@
+{
+ "now": {
+ "date": "2020-03-08T18:10:16Z",
+ "hour_num": "18",
+ "min_num": "10",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "lmp",
+ "price_ckwh": "1.26900000000000000000",
+ "value_score": "11",
+ "mean_price_ckwh": "1.429706",
+ "diff_mean_ckwh": "-0.160706",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.544065",
+ "price_display": "1.3",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-08T13:10:16-05:00"
+ },
+ "forecast": [
+ {
+ "date": "2020-03-08T19:00:00Z",
+ "hour_num": "19",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.32000000",
+ "value_score": "12",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.113030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.3",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-08T14:00:00-05:00"
+ },
+ {
+ "date": "2020-03-08T20:00:00Z",
+ "hour_num": "20",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.37400000",
+ "value_score": "12",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.059030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.4",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-08T15:00:00-05:00"
+ },
+ {
+ "date": "2020-03-08T21:00:00Z",
+ "hour_num": "21",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.44700000",
+ "value_score": "13",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "0.013970",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.4",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-08T16:00:00-05:00"
+ },
+ {
+ "date": "2020-03-08T22:00:00Z",
+ "hour_num": "22",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.52600000",
+ "value_score": "13",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "0.092970",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.5",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-08T17:00:00-05:00"
+ },
+ {
+ "date": "2020-03-08T23:00:00Z",
+ "hour_num": "23",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "2.05100000",
+ "value_score": "17",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "0.617970",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "2.1",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-08T18:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T00:00:00Z",
+ "hour_num": "0",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "2.07400000",
+ "value_score": "17",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "0.640970",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "2.1",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-08T19:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T01:00:00Z",
+ "hour_num": "1",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.94400000",
+ "value_score": "16",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "0.510970",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.9",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-08T20:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T02:00:00Z",
+ "hour_num": "2",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.57500000",
+ "value_score": "14",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "0.141970",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.6",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-08T21:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T03:00:00Z",
+ "hour_num": "3",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.23700000",
+ "value_score": "11",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.196030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.2",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-08T22:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T04:00:00Z",
+ "hour_num": "4",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "0.96200000",
+ "value_score": "9",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.471030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-08T23:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T05:00:00Z",
+ "hour_num": "5",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "0.80000000",
+ "value_score": "8",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.633030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "0.8",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T00:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T06:00:00Z",
+ "hour_num": "6",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "0.70000000",
+ "value_score": "7",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.733030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "0.7",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T01:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T07:00:00Z",
+ "hour_num": "7",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "0.70000000",
+ "value_score": "7",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.733030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "0.7",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T02:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T08:00:00Z",
+ "hour_num": "8",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "0.70000000",
+ "value_score": "7",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.733030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "0.7",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T03:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T09:00:00Z",
+ "hour_num": "9",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "0.70000000",
+ "value_score": "7",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.733030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "0.7",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T04:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T10:00:00Z",
+ "hour_num": "10",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "0.90000000",
+ "value_score": "8",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.533030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "0.9",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T05:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T11:00:00Z",
+ "hour_num": "11",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.00000000",
+ "value_score": "9",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.433030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T06:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T12:00:00Z",
+ "hour_num": "12",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.10000000",
+ "value_score": "10",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.333030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.1",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T07:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T13:00:00Z",
+ "hour_num": "13",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.10000000",
+ "value_score": "10",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.333030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.1",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T08:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T14:00:00Z",
+ "hour_num": "14",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.20000000",
+ "value_score": "11",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.233030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.2",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T09:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T15:00:00Z",
+ "hour_num": "15",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.20000000",
+ "value_score": "11",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.233030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.2",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T10:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T16:00:00Z",
+ "hour_num": "16",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.30000000",
+ "value_score": "11",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.133030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.3",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T11:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T17:00:00Z",
+ "hour_num": "17",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.40000000",
+ "value_score": "12",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.033030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.4",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T12:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T18:00:00Z",
+ "hour_num": "18",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.50000000",
+ "value_score": "13",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "0.066970",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.5",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T13:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T19:00:00Z",
+ "hour_num": "19",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.60000000",
+ "value_score": "14",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "0.166970",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.6",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T14:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T20:00:00Z",
+ "hour_num": "20",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.60000000",
+ "value_score": "14",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "0.166970",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.6",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T15:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T21:00:00Z",
+ "hour_num": "21",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.60000000",
+ "value_score": "14",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "0.166970",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.6",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T16:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T22:00:00Z",
+ "hour_num": "22",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "2.10000000",
+ "value_score": "18",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "0.666970",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "2.1",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T17:00:00-05:00"
+ },
+ {
+ "date": "2020-03-09T23:00:00Z",
+ "hour_num": "23",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "3.20000000",
+ "value_score": "27",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "1.766970",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "3.2",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T18:00:00-05:00"
+ },
+ {
+ "date": "2020-03-10T00:00:00Z",
+ "hour_num": "0",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "2.40000000",
+ "value_score": "20",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "0.966970",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "2.4",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T19:00:00-05:00"
+ },
+ {
+ "date": "2020-03-10T01:00:00Z",
+ "hour_num": "1",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "2.00000000",
+ "value_score": "17",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "0.566970",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "2",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T20:00:00-05:00"
+ },
+ {
+ "date": "2020-03-10T02:00:00Z",
+ "hour_num": "2",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.70000000",
+ "value_score": "15",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "0.266970",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.7",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T21:00:00-05:00"
+ },
+ {
+ "date": "2020-03-10T03:00:00Z",
+ "hour_num": "3",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.40000000",
+ "value_score": "12",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.033030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.4",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T22:00:00-05:00"
+ },
+ {
+ "date": "2020-03-10T04:00:00Z",
+ "hour_num": "4",
+ "min_num": "0",
+ "settlement_point": "LZ_HOUSTON",
+ "price_type": "dam",
+ "price_ckwh": "1.20000000",
+ "value_score": "11",
+ "mean_price_ckwh": "1.433030",
+ "diff_mean_ckwh": "-0.233030",
+ "high_ckwh": "3.200000",
+ "low_ckwh": "0.700000",
+ "std_dev_ckwh": "0.552149",
+ "price_display": "1.2",
+ "price_display_sign": "¢",
+ "date_local_tz": "2020-03-09T23:00:00-05:00"
+ }
+ ],
+ "seconds_until_refresh": "26"
+}
diff --git a/tests/fixtures/homekit_controller/ecobee_occupancy.json b/tests/fixtures/homekit_controller/ecobee_occupancy.json
new file mode 100644
index 00000000000..78c98599961
--- /dev/null
+++ b/tests/fixtures/homekit_controller/ecobee_occupancy.json
@@ -0,0 +1,236 @@
+[
+ {
+ "aid": 1,
+ "services": [
+ {
+ "characteristics": [
+ {
+ "format": "string",
+ "iid": 2,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "Master Fan"
+ },
+ {
+ "format": "string",
+ "iid": 3,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000020-0000-1000-8000-0026BB765291",
+ "value": "ecobee Inc."
+ },
+ {
+ "format": "string",
+ "iid": 4,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000030-0000-1000-8000-0026BB765291",
+ "value": "111111111111"
+ },
+ {
+ "format": "string",
+ "iid": 5,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000021-0000-1000-8000-0026BB765291",
+ "value": "ecobee Switch+"
+ },
+ {
+ "format": "bool",
+ "iid": 6,
+ "perms": [
+ "pw"
+ ],
+ "type": "00000014-0000-1000-8000-0026BB765291"
+ },
+ {
+ "format": "string",
+ "iid": 8,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000052-0000-1000-8000-0026BB765291",
+ "value": "4.5.130201"
+ },
+ {
+ "format": "uint32",
+ "iid": 9,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "000000A6-0000-1000-8000-0026BB765291",
+ "value": 0
+ }
+ ],
+ "iid": 1,
+ "stype": "accessory-information",
+ "type": "0000003E-0000-1000-8000-0026BB765291"
+ },
+ {
+ "characteristics": [
+ {
+ "format": "string",
+ "iid": 31,
+ "maxLen": 64,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000037-0000-1000-8000-0026BB765291",
+ "value": "1.1.0"
+ }
+ ],
+ "iid": 30,
+ "stype": "service",
+ "type": "000000A2-0000-1000-8000-0026BB765291"
+ },
+ {
+ "characteristics": [
+ {
+ "format": "bool",
+ "iid": 17,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "00000025-0000-1000-8000-0026BB765291",
+ "value": false
+ },
+ {
+ "format": "string",
+ "iid": 18,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "Master Fan"
+ }
+ ],
+ "iid": 16,
+ "primary": true,
+ "stype": "switch",
+ "type": "00000049-0000-1000-8000-0026BB765291"
+ },
+ {
+ "characteristics": [
+ {
+ "format": "float",
+ "iid": 20,
+ "maxValue": 100000,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "0000006B-0000-1000-8000-0026BB765291",
+ "unit": "lux",
+ "value": 0
+ },
+ {
+ "format": "string",
+ "iid": 21,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "Master Fan"
+ }
+ ],
+ "iid": 27,
+ "stype": "light",
+ "type": "00000084-0000-1000-8000-0026BB765291"
+ },
+ {
+ "characteristics": [
+ {
+ "format": "bool",
+ "iid": 66,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "00000022-0000-1000-8000-0026BB765291",
+ "value": false
+ },
+ {
+ "format": "string",
+ "iid": 28,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "Master Fan"
+ }
+ ],
+ "iid": 56,
+ "stype": "motion",
+ "type": "00000085-0000-1000-8000-0026BB765291"
+ },
+ {
+ "characteristics": [
+ {
+ "format": "uint8",
+ "iid": 65,
+ "maxValue": 1,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "00000071-0000-1000-8000-0026BB765291",
+ "value": 0
+ },
+ {
+ "format": "string",
+ "iid": 29,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "Master Fan"
+ }
+ ],
+ "iid": 57,
+ "stype": "occupancy",
+ "type": "00000086-0000-1000-8000-0026BB765291"
+ },
+ {
+ "characteristics": [
+ {
+ "format": "float",
+ "iid": 19,
+ "maxValue": 100,
+ "minStep": 0.1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "00000011-0000-1000-8000-0026BB765291",
+ "unit": "celsius",
+ "value": 25.6
+ },
+ {
+ "format": "string",
+ "iid": 22,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "Master Fan"
+ }
+ ],
+ "iid": 55,
+ "stype": "temperature",
+ "type": "0000008A-0000-1000-8000-0026BB765291"
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/homekit_controller/lg_tv.json b/tests/fixtures/homekit_controller/lg_tv.json
new file mode 100644
index 00000000000..26b3557c2e6
--- /dev/null
+++ b/tests/fixtures/homekit_controller/lg_tv.json
@@ -0,0 +1,1059 @@
+[
+ {
+ "aid": 1,
+ "services": [
+ {
+ "characteristics": [
+ {
+ "format": "bool",
+ "iid": 2,
+ "perms": [
+ "pw"
+ ],
+ "type": "00000014-0000-1000-8000-0026BB765291"
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 3,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000020-0000-1000-8000-0026BB765291",
+ "value": "LG Electronics"
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 4,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000021-0000-1000-8000-0026BB765291",
+ "value": "OLED55B9PUA"
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 5,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "LG webOS TV AF80"
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 6,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000030-0000-1000-8000-0026BB765291",
+ "value": "999AAAAAA999"
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 7,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000052-0000-1000-8000-0026BB765291",
+ "value": "04.71.04"
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 8,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000053-0000-1000-8000-0026BB765291",
+ "value": "1"
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 9,
+ "perms": [
+ "pr",
+ "hd"
+ ],
+ "type": "34AB8811-AC7F-4340-BAC3-FD6A85F9943B",
+ "value": "2.1;16B62a"
+ }
+ ],
+ "hidden": false,
+ "iid": 1,
+ "linked": [],
+ "primary": false,
+ "stype": "accessory-information",
+ "type": "0000003E-0000-1000-8000-0026BB765291"
+ },
+ {
+ "characteristics": [
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 18,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000037-0000-1000-8000-0026BB765291",
+ "value": "1.1.0"
+ }
+ ],
+ "hidden": false,
+ "iid": 16,
+ "linked": [],
+ "primary": false,
+ "stype": "service",
+ "type": "000000A2-0000-1000-8000-0026BB765291"
+ },
+ {
+ "characteristics": [
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 50,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "LG webOS TV"
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 51,
+ "maxLen": 25,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "E3",
+ "value": "LG webOS TV OLED55B9PUA"
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 52,
+ "maxValue": 1,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "000000B0-0000-1000-8000-0026BB765291",
+ "value": 1
+ },
+ {
+ "ev": false,
+ "format": "uint32",
+ "iid": 53,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "E7",
+ "value": 6
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 54,
+ "maxValue": 2,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "E8",
+ "value": 1
+ },
+ {
+ "format": "uint8",
+ "iid": 57,
+ "maxValue": 1,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pw"
+ ],
+ "type": "DF"
+ },
+ {
+ "format": "uint8",
+ "iid": 59,
+ "maxValue": 16,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pw"
+ ],
+ "type": "E1"
+ }
+ ],
+ "hidden": false,
+ "iid": 48,
+ "linked": [
+ 64,
+ 80,
+ 384,
+ 256,
+ 272,
+ 288,
+ 304,
+ 320,
+ 336,
+ 352
+ ],
+ "primary": true,
+ "stype": "Unknown Service: D8",
+ "type": "D8"
+ },
+ {
+ "characteristics": [
+ {
+ "ev": false,
+ "format": "uint16",
+ "iid": 66,
+ "maxValue": 2,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "E5",
+ "value": 0
+ },
+ {
+ "ev": false,
+ "format": "tlv8",
+ "iid": 67,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "E4",
+ "value": "AQACAQA="
+ }
+ ],
+ "hidden": false,
+ "iid": 64,
+ "linked": [],
+ "primary": false,
+ "stype": "Unknown Service: DA",
+ "type": "DA"
+ },
+ {
+ "characteristics": [
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 84,
+ "maxValue": 100,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "00000119-0000-1000-8000-0026BB765291",
+ "unit": "percentage",
+ "value": 0
+ },
+ {
+ "ev": false,
+ "format": "bool",
+ "iid": 82,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "0000011A-0000-1000-8000-0026BB765291",
+ "value": 0
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 83,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "Speaker"
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 85,
+ "maxValue": 1,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "000000B0-0000-1000-8000-0026BB765291",
+ "value": 1
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 86,
+ "maxValue": 3,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "E9",
+ "value": 2
+ },
+ {
+ "format": "uint8",
+ "iid": 87,
+ "maxValue": 1,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pw"
+ ],
+ "type": "EA"
+ }
+ ],
+ "hidden": false,
+ "iid": 80,
+ "linked": [],
+ "primary": false,
+ "stype": "speaker",
+ "type": "00000113-0000-1000-8000-0026BB765291"
+ },
+ {
+ "characteristics": [
+ {
+ "ev": false,
+ "format": "tlv8",
+ "iid": 385,
+ "perms": [
+ "pr"
+ ],
+ "type": "222",
+ "value": "AQgBBnRAvoQmJQIaAQYgF0KJBUICBiAXQokFQgAAAgZ0QL6EJiQ="
+ }
+ ],
+ "hidden": false,
+ "iid": 384,
+ "linked": [],
+ "primary": false,
+ "stype": "Unknown Service: 221",
+ "type": "221"
+ },
+ {
+ "characteristics": [
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 258,
+ "maxValue": 10,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "DB",
+ "value": 8
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 259,
+ "maxValue": 1,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "D6",
+ "value": 1
+ },
+ {
+ "ev": false,
+ "format": "uint32",
+ "iid": 260,
+ "perms": [
+ "pr"
+ ],
+ "type": "E6",
+ "value": 1
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 261,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "AirPlay"
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 262,
+ "maxLen": 25,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "E3",
+ "value": "AirPlay"
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 264,
+ "maxValue": 6,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "DC",
+ "value": 0
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 263,
+ "maxValue": 3,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "135",
+ "value": 3
+ }
+ ],
+ "hidden": false,
+ "iid": 256,
+ "linked": [],
+ "primary": false,
+ "stype": "Unknown Service: D9",
+ "type": "D9"
+ },
+ {
+ "characteristics": [
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 274,
+ "maxValue": 10,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "DB",
+ "value": 2
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 275,
+ "maxValue": 1,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "D6",
+ "value": 1
+ },
+ {
+ "ev": false,
+ "format": "uint32",
+ "iid": 276,
+ "perms": [
+ "pr"
+ ],
+ "type": "E6",
+ "value": 2
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 277,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "Live TV"
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 278,
+ "maxLen": 25,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "E3",
+ "value": "Live TV"
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 280,
+ "maxValue": 6,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "DC",
+ "value": 3
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 279,
+ "maxValue": 3,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "135",
+ "value": 3
+ }
+ ],
+ "hidden": false,
+ "iid": 272,
+ "linked": [],
+ "primary": false,
+ "stype": "Unknown Service: D9",
+ "type": "D9"
+ },
+ {
+ "characteristics": [
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 290,
+ "maxValue": 10,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "DB",
+ "value": 3
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 291,
+ "maxValue": 1,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "D6",
+ "value": 1
+ },
+ {
+ "ev": false,
+ "format": "uint32",
+ "iid": 292,
+ "perms": [
+ "pr"
+ ],
+ "type": "E6",
+ "value": 3
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 293,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "HDMI 1"
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 294,
+ "maxLen": 25,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "E3",
+ "value": "HDMI 1"
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 296,
+ "maxValue": 6,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "DC",
+ "value": 4
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 295,
+ "maxValue": 3,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "135",
+ "value": 1
+ }
+ ],
+ "hidden": false,
+ "iid": 288,
+ "linked": [],
+ "primary": false,
+ "stype": "Unknown Service: D9",
+ "type": "D9"
+ },
+ {
+ "characteristics": [
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 306,
+ "maxValue": 10,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "DB",
+ "value": 3
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 307,
+ "maxValue": 1,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "D6",
+ "value": 1
+ },
+ {
+ "ev": false,
+ "format": "uint32",
+ "iid": 308,
+ "perms": [
+ "pr"
+ ],
+ "type": "E6",
+ "value": 4
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 309,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "HDMI 2"
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 310,
+ "maxLen": 25,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "E3",
+ "value": "Sony"
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 312,
+ "maxValue": 6,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "DC",
+ "value": 4
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 311,
+ "maxValue": 3,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "135",
+ "value": 2
+ }
+ ],
+ "hidden": false,
+ "iid": 304,
+ "linked": [],
+ "primary": false,
+ "stype": "Unknown Service: D9",
+ "type": "D9"
+ },
+ {
+ "characteristics": [
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 322,
+ "maxValue": 10,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "DB",
+ "value": 3
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 323,
+ "maxValue": 1,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "D6",
+ "value": 1
+ },
+ {
+ "ev": false,
+ "format": "uint32",
+ "iid": 324,
+ "perms": [
+ "pr"
+ ],
+ "type": "E6",
+ "value": 5
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 325,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "HDMI 3"
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 326,
+ "maxLen": 25,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "E3",
+ "value": "Apple"
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 328,
+ "maxValue": 6,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "DC",
+ "value": 4
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 327,
+ "maxValue": 3,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "135",
+ "value": 2
+ }
+ ],
+ "hidden": false,
+ "iid": 320,
+ "linked": [],
+ "primary": false,
+ "stype": "Unknown Service: D9",
+ "type": "D9"
+ },
+ {
+ "characteristics": [
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 338,
+ "maxValue": 10,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "DB",
+ "value": 4
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 339,
+ "maxValue": 1,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "D6",
+ "value": 1
+ },
+ {
+ "ev": false,
+ "format": "uint32",
+ "iid": 340,
+ "perms": [
+ "pr"
+ ],
+ "type": "E6",
+ "value": 7
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 341,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "AV"
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 342,
+ "maxLen": 25,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "E3",
+ "value": "AV"
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 344,
+ "maxValue": 6,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "DC",
+ "value": 2
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 343,
+ "maxValue": 3,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "135",
+ "value": 1
+ }
+ ],
+ "hidden": false,
+ "iid": 336,
+ "linked": [],
+ "primary": false,
+ "stype": "Unknown Service: D9",
+ "type": "D9"
+ },
+ {
+ "characteristics": [
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 354,
+ "maxValue": 10,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "DB",
+ "value": 3
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 355,
+ "maxValue": 1,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "D6",
+ "value": 1
+ },
+ {
+ "ev": false,
+ "format": "uint32",
+ "iid": 356,
+ "perms": [
+ "pr"
+ ],
+ "type": "E6",
+ "value": 6
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 357,
+ "perms": [
+ "pr"
+ ],
+ "type": "00000023-0000-1000-8000-0026BB765291",
+ "value": "HDMI 4"
+ },
+ {
+ "ev": false,
+ "format": "string",
+ "iid": 358,
+ "maxLen": 25,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "E3",
+ "value": "HDMI 4"
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 360,
+ "maxValue": 6,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "DC",
+ "value": 4
+ },
+ {
+ "ev": false,
+ "format": "uint8",
+ "iid": 359,
+ "maxValue": 3,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "135",
+ "value": 2
+ }
+ ],
+ "hidden": false,
+ "iid": 352,
+ "linked": [],
+ "primary": false,
+ "stype": "Unknown Service: D9",
+ "type": "D9"
+ }
+ ]
+ }
+]
diff --git a/tests/fixtures/yandex_transport_reply.json b/tests/fixtures/yandex_transport_reply.json
index 3189d7a9d9b..af167553b6e 100644
--- a/tests/fixtures/yandex_transport_reply.json
+++ b/tests/fixtures/yandex_transport_reply.json
@@ -1,1377 +1,1620 @@
{
"data": {
- "geometries": [
- {
- "type": "Point",
- "coordinates": [
- 37.565280044,
- 55.851959656
- ]
- }
+ "id": "stop__9639579",
+ "name": "7-й автобусный парк",
+ "coordinates": [
+ 37.565280044,
+ 55.851959656
],
- "geometry": {
- "type": "Point",
- "coordinates": [
- 37.565280044,
- 55.851959656
- ]
- },
- "properties": {
- "name": "7-й автобусный парк",
- "description": "7-й автобусный парк",
- "currentTime": 1570971868567,
- "tzOffset": 10800,
- "StopMetaData": {
- "id": "stop__9639579",
- "name": "7-й автобусный парк",
- "type": "urban",
- "region": {
- "id": 213,
- "type": 6,
- "parent_id": 1,
- "capital_id": 0,
+ "currentTime": 1583421546364,
+ "tzOffset": 10800,
+ "type": "urban",
+ "region": {
+ "id": 213,
+ "type": 6,
+ "parent_id": 1,
+ "capital_id": 0,
+ "geo_parent_id": 0,
+ "city_id": 213,
+ "name": "Москва",
+ "native_name": "",
+ "iso_name": "RU MOW",
+ "is_main": true,
+ "en_name": "Moscow",
+ "short_en_name": "MSK",
+ "phone_code": "495 499",
+ "phone_code_old": "095",
+ "zip_code": "",
+ "population": 12506468,
+ "synonyms": "Moskau, Moskva",
+ "latitude": 55.753215,
+ "longitude": 37.622504,
+ "latitude_size": 0.878654,
+ "longitude_size": 1.164423,
+ "zoom": 10,
+ "tzname": "Europe/Moscow",
+ "official_languages": "ru",
+ "widespread_languages": "ru",
+ "suggest_list": [],
+ "is_eu": false,
+ "services_names": [
+ "bs",
+ "yaca",
+ "weather",
+ "afisha",
+ "maps",
+ "tv",
+ "ad",
+ "etrain",
+ "subway",
+ "delivery",
+ "route"
+ ],
+ "seoname": "moscow",
+ "bounds": [
+ [
+ 37.0402925,
+ 55.31141404514547
+ ],
+ [
+ 38.2047155,
+ 56.190068045145466
+ ]
+ ],
+ "names": {
+ "ablative": "",
+ "accusative": "Москву",
+ "dative": "Москве",
+ "directional": "",
+ "genitive": "Москвы",
+ "instrumental": "Москвой",
+ "locative": "",
+ "nominative": "Москва",
+ "preposition": "в",
+ "prepositional": "Москве"
+ },
+ "parent": {
+ "id": 1,
+ "type": 5,
+ "parent_id": 3,
+ "capital_id": 213,
+ "geo_parent_id": 0,
+ "city_id": 213,
+ "name": "Москва и Московская область",
+ "native_name": "",
+ "iso_name": "RU-MOS",
+ "is_main": true,
+ "en_name": "Moscow and Moscow Oblast",
+ "short_en_name": "RU-MOS",
+ "phone_code": "495 496 498 499",
+ "phone_code_old": "",
+ "zip_code": "",
+ "population": 7503385,
+ "synonyms": "Московская область, Подмосковье, Podmoskovye",
+ "latitude": 55.815792,
+ "longitude": 37.380031,
+ "latitude_size": 2.705659,
+ "longitude_size": 5.060749,
+ "zoom": 8,
+ "tzname": "Europe/Moscow",
+ "official_languages": "ru",
+ "widespread_languages": "ru",
+ "suggest_list": [
+ 213,
+ 10716,
+ 10747,
+ 10758,
+ 20728,
+ 10740,
+ 10738,
+ 20523,
+ 10735,
+ 10734,
+ 10743,
+ 21622
+ ],
+ "is_eu": false,
+ "services_names": [
+ "bs",
+ "yaca",
+ "ad"
+ ],
+ "seoname": "moscow-and-moscow-oblast",
+ "bounds": [
+ [
+ 34.8496565,
+ 54.439456064325434
+ ],
+ [
+ 39.9104055,
+ 57.14511506432543
+ ]
+ ],
+ "names": {
+ "ablative": "",
+ "accusative": "Москву и Московскую область",
+ "dative": "Москве и Московской области",
+ "directional": "",
+ "genitive": "Москвы и Московской области",
+ "instrumental": "Москвой и Московской областью",
+ "locative": "",
+ "nominative": "Москва и Московская область",
+ "preposition": "в",
+ "prepositional": "Москве и Московской области"
+ },
+ "parent": {
+ "id": 225,
+ "type": 3,
+ "parent_id": 10001,
+ "capital_id": 213,
"geo_parent_id": 0,
"city_id": 213,
- "name": "moscow",
+ "name": "Россия",
"native_name": "",
- "iso_name": "RU MOW",
- "is_main": true,
- "en_name": "Moscow",
- "short_en_name": "MSK",
- "phone_code": "495 499",
- "phone_code_old": "095",
+ "iso_name": "RU",
+ "is_main": false,
+ "en_name": "Russia",
+ "short_en_name": "RU",
+ "phone_code": "7",
+ "phone_code_old": "",
"zip_code": "",
- "population": 12506468,
- "synonyms": "Moskau, Moskva",
- "latitude": 55.753215,
- "longitude": 37.622504,
- "latitude_size": 0.878654,
- "longitude_size": 1.164423,
- "zoom": 10,
- "tzname": "Europe/Moscow",
+ "population": 146880432,
+ "synonyms": "Russian Federation,Российская Федерация",
+ "latitude": 61.698653,
+ "longitude": 99.505405,
+ "latitude_size": 40.700127,
+ "longitude_size": 171.643239,
+ "zoom": 3,
+ "tzname": "",
"official_languages": "ru",
"widespread_languages": "ru",
- "suggest_list": [],
+ "suggest_list": [
+ 213,
+ 2,
+ 65,
+ 54,
+ 47,
+ 43,
+ 66,
+ 51,
+ 56,
+ 172,
+ 39,
+ 62
+ ],
"is_eu": false,
"services_names": [
"bs",
"yaca",
- "weather",
- "afisha",
- "maps",
- "tv",
- "ad",
- "etrain",
- "subway",
- "delivery",
- "route"
+ "ad"
],
- "ename": "moscow",
+ "seoname": "russia",
"bounds": [
[
- 37.0402925,
- 55.31141404514547
+ 13.683785499999999,
+ 35.290400699917846
],
[
- 38.2047155,
- 56.190068045145466
+ -174.6729755,
+ 75.99052769991785
]
],
"names": {
"ablative": "",
- "accusative": "Москву",
- "dative": "Москве",
+ "accusative": "Россию",
+ "dative": "России",
"directional": "",
- "genitive": "Москвы",
- "instrumental": "Москвой",
+ "genitive": "России",
+ "instrumental": "Россией",
"locative": "",
- "nominative": "Москва",
+ "nominative": "Россия",
"preposition": "в",
- "prepositional": "Москве"
- },
- "parent": {
- "id": 1,
- "type": 5,
- "parent_id": 3,
- "capital_id": 213,
- "geo_parent_id": 0,
- "city_id": 213,
- "name": "moscow-and-moscow-oblast",
- "native_name": "",
- "iso_name": "RU-MOS",
- "is_main": true,
- "en_name": "Moscow and Moscow Oblast",
- "short_en_name": "RU-MOS",
- "phone_code": "495 496 498 499",
- "phone_code_old": "",
- "zip_code": "",
- "population": 7503385,
- "synonyms": "Московская область, Подмосковье, Podmoskovye",
- "latitude": 55.815792,
- "longitude": 37.380031,
- "latitude_size": 2.705659,
- "longitude_size": 5.060749,
- "zoom": 8,
- "tzname": "Europe/Moscow",
- "official_languages": "ru",
- "widespread_languages": "ru",
- "suggest_list": [
- 213,
- 10716,
- 10747,
- 10758,
- 20728,
- 10740,
- 10738,
- 20523,
- 10735,
- 10734,
- 10743,
- 21622
+ "prepositional": "России"
+ }
+ }
+ }
+ },
+ "transports": [
+ {
+ "lineId": "2036924633",
+ "name": "215",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "213B_215_bus_mosgortrans",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9711780",
+ "name": "Метро Петровско-Разумовская"
+ },
+ {
+ "id": "stop__9711744",
+ "name": "Станция Ховрино"
+ }
],
- "is_eu": false,
- "services_names": [
- "bs",
- "yaca",
- "ad"
- ],
- "ename": "moscow-and-moscow-oblast",
- "bounds": [
- [
- 34.8496565,
- 54.439456064325434
- ],
- [
- 39.9104055,
- 57.14511506432543
- ]
- ],
- "names": {
- "ablative": "",
- "accusative": "Москву и Московскую область",
- "dative": "Москве и Московской области",
- "directional": "",
- "genitive": "Москвы и Московской области",
- "instrumental": "Москвой и Московской областью",
- "locative": "",
- "nominative": "Москва и Московская область",
- "preposition": "в",
- "prepositional": "Москве и Московской области"
- },
- "parent": {
- "id": 225,
- "type": 3,
- "parent_id": 10001,
- "capital_id": 213,
- "geo_parent_id": 0,
- "city_id": 213,
- "name": "russia",
- "native_name": "",
- "iso_name": "RU",
- "is_main": false,
- "en_name": "Russia",
- "short_en_name": "RU",
- "phone_code": "7",
- "phone_code_old": "",
- "zip_code": "",
- "population": 146880432,
- "synonyms": "Russian Federation,Российская Федерация",
- "latitude": 61.698653,
- "longitude": 99.505405,
- "latitude_size": 40.700127,
- "longitude_size": 171.643239,
- "zoom": 3,
- "tzname": "",
- "official_languages": "ru",
- "widespread_languages": "ru",
- "suggest_list": [
- 213,
- 2,
- 65,
- 54,
- 47,
- 43,
- 66,
- 51,
- 56,
- 172,
- 39,
- 62
- ],
- "is_eu": false,
- "services_names": [
- "bs",
- "yaca",
- "ad"
- ],
- "ename": "russia",
- "bounds": [
- [
- 13.683785499999999,
- 35.290400699917846
- ],
- [
- -174.6729755,
- 75.99052769991785
- ]
- ],
- "names": {
- "ablative": "",
- "accusative": "Россию",
- "dative": "России",
- "directional": "",
- "genitive": "России",
- "instrumental": "Россией",
- "locative": "",
- "nominative": "Россия",
- "preposition": "в",
- "prepositional": "России"
+ "BriefSchedule": {
+ "Events": [],
+ "Frequency": {
+ "text": "27 мин",
+ "value": 1620,
+ "begin": {
+ "value": "1583375676",
+ "tzOffset": 10800,
+ "text": "5:34"
+ },
+ "end": {
+ "value": "1583445876",
+ "tzOffset": 10800,
+ "text": "1:04"
+ }
}
}
}
- },
- "Transport": [
+ ],
+ "uri": "ymapsbm1://transit/line?id=2036924633&ll=37.543816%2C55.854638&name=215&r=2766&type=bus",
+ "seoname": "215"
+ },
+ {
+ "lineId": "2036924720",
+ "name": "692",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
{
- "lineId": "2036924720",
- "name": "692",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
+ "threadId": "2036928706",
+ "noBoarding": false,
+ "EssentialStops": [
{
- "threadId": "2036928706",
- "EssentialStops": [
- {
- "id": "3163417967",
- "name": "Платформа Дегунино"
- },
- {
- "id": "3163417967",
- "name": "Платформа Дегунино"
- }
- ],
- "BriefSchedule": {
- "Events": [
- {
- "Estimated": {
- "value": "1570973441",
- "tzOffset": 10800,
- "text": "16:30"
- },
- "vehicleId": "codd%5Fnew|144020%5F31402"
- }
- ],
- "Frequency": {
- "text": "1 ч",
- "value": 3600,
- "begin": {
- "value": "1570938428",
- "tzOffset": 10800,
- "text": "6:47"
- },
- "end": {
- "value": "1570990628",
- "tzOffset": 10800,
- "text": "21:17"
- }
- }
- }
+ "id": "3163417967",
+ "name": "Станция Дегунино"
+ },
+ {
+ "id": "3163417967",
+ "name": "Станция Дегунино"
}
],
- "uri": "ymapsbm1://transit/line?id=2036924720&ll=37.577436%2C55.828981&name=692&r=4037&type=bus",
- "seoname": "692"
- },
- {
- "lineId": "2036924968",
- "name": "82",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "2036925244",
- "EssentialStops": [
- {
- "id": "2310890052",
- "name": "Метро Верхние Лихоборы"
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Scheduled": {
+ "value": "1583421600",
+ "tzOffset": 10800,
+ "text": "18:20"
},
- {
- "id": "2310890052",
- "name": "Метро Верхние Лихоборы"
- }
- ],
- "BriefSchedule": {
- "Events": [],
- "Frequency": {
- "text": "34 мин",
- "value": 2040,
- "begin": {
- "value": "1570944072",
- "tzOffset": 10800,
- "text": "8:21"
- },
- "end": {
- "value": "1570997592",
- "tzOffset": 10800,
- "text": "23:13"
- }
+ "Estimated": {
+ "value": "1583422070",
+ "tzOffset": 10800,
+ "text": "18:27"
+ },
+ "vehicleId": "codd%5Fnew|144020%5F31402"
+ },
+ {
+ "Scheduled": {
+ "value": "1583424060",
+ "tzOffset": 10800,
+ "text": "19:01"
+ },
+ "Estimated": {
+ "value": "1583423194",
+ "tzOffset": 10800,
+ "text": "18:46"
+ },
+ "vehicleId": "codd%5Fnew|1115930%5F31497"
+ },
+ {
+ "Scheduled": {
+ "value": "1583425380",
+ "tzOffset": 10800,
+ "text": "19:23"
}
}
- }
- ],
- "uri": "ymapsbm1://transit/line?id=2036924968&ll=37.571504%2C55.816622&name=82&r=4164&type=bus",
- "seoname": "82"
- },
- {
- "lineId": "2036925416",
- "name": "194",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "2036927196",
- "EssentialStops": [
- {
- "id": "stop__9711780",
- "name": "Метро Петровско-Разумовская"
- },
- {
- "id": "stop__9648742",
- "name": "Коровино"
- }
- ],
- "BriefSchedule": {
- "Events": [],
- "Frequency": {
- "text": "12 мин",
- "value": 720,
- "begin": {
- "value": "1570933976",
- "tzOffset": 10800,
- "text": "5:32"
- },
- "end": {
- "value": "1571004356",
- "tzOffset": 10800,
- "text": "1:05"
- }
- }
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=2036925416&ll=37.544800%2C55.865286&name=194&r=3667&type=bus",
- "seoname": "194"
- },
- {
- "lineId": "2036925728",
- "name": "282",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "213A_282_bus_mosgortrans",
- "EssentialStops": [
- {
- "id": "stop__9641102",
- "name": "Улица Корнейчука"
- },
- {
- "id": "2532226085",
- "name": "Метро Войковская"
- }
- ],
- "BriefSchedule": {
- "Events": [
- {
- "Estimated": {
- "value": "1570971861",
- "tzOffset": 10800,
- "text": "16:04"
- },
- "vehicleId": "codd%5Fnew|34854%5F9345401"
- },
- {
- "Estimated": {
- "value": "1570973231",
- "tzOffset": 10800,
- "text": "16:27"
- },
- "vehicleId": "codd%5Fnew|37913%5F9225419"
- }
- ],
- "Frequency": {
- "text": "22 мин",
- "value": 1320,
- "begin": {
- "value": "1570934963",
- "tzOffset": 10800,
- "text": "5:49"
- },
- "end": {
- "value": "1571005163",
- "tzOffset": 10800,
- "text": "1:19"
- }
- }
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=2036925728&ll=37.553526%2C55.860385&name=282&r=5779&type=bus",
- "seoname": "282"
- },
- {
- "lineId": "2036926781",
- "name": "154",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "213B_154_bus_mosgortrans",
- "EssentialStops": [
- {
- "id": "stop__9642548",
- "name": "ВДНХ (южная)"
- },
- {
- "id": "stop__9711744",
- "name": "Станция Ховрино"
- }
- ],
- "BriefSchedule": {
- "Events": [
- {
- "Estimated": {
- "value": "1570972424",
- "tzOffset": 10800,
- "text": "16:13"
- },
- "vehicleId": "codd%5Fnew|1161539%5F191543"
- },
- {
- "Estimated": {
- "value": "1570973620",
- "tzOffset": 10800,
- "text": "16:33"
- },
- "vehicleId": "codd%5Fnew|58773%5F190599"
- }
- ],
- "Frequency": {
- "text": "20 мин",
- "value": 1200,
- "begin": {
- "value": "1570938166",
- "tzOffset": 10800,
- "text": "6:42"
- },
- "end": {
- "value": "1571006446",
- "tzOffset": 10800,
- "text": "1:40"
- }
- }
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=2036926781&ll=37.576158%2C55.846301&name=154&r=4917&type=bus",
- "seoname": "154"
- },
- {
- "lineId": "2036926818",
- "name": "994",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "213A_294m_minibus_default",
- "EssentialStops": [
- {
- "id": "stop__9640756",
- "name": "Метро Петровско-Разумовская"
- },
- {
- "id": "stop__9649459",
- "name": "Метро Алтуфьево"
- }
- ],
- "BriefSchedule": {
- "Events": [],
- "Frequency": {
- "text": "30 мин",
- "value": 1800,
- "begin": {
- "value": "1570934327",
- "tzOffset": 10800,
- "text": "5:38"
- },
- "end": {
- "value": "1571004527",
- "tzOffset": 10800,
- "text": "1:08"
- }
- }
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=2036926818&ll=37.560060%2C55.868431&name=994&r=3637&type=bus",
- "seoname": "994"
- },
- {
- "lineId": "2036926890",
- "name": "466",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "466B_bus_default",
- "EssentialStops": [
- {
- "id": "stop__9640546",
- "name": "Станция Бескудниково"
- },
- {
- "id": "stop__9640545",
- "name": "Станция Бескудниково"
- }
- ],
- "BriefSchedule": {
- "Events": [],
- "Frequency": {
- "text": "22 мин",
- "value": 1320,
- "begin": {
- "value": "1570937447",
- "tzOffset": 10800,
- "text": "6:30"
- },
- "end": {
- "value": "1571008247",
- "tzOffset": 10800,
- "text": "2:10"
- }
- }
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=2036926890&ll=37.564238%2C55.845050&name=466&r=4163&type=bus",
- "seoname": "466"
- },
- {
- "lineId": "213_114_bus_mosgortrans",
- "name": "114",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "213B_114_bus_mosgortrans",
- "EssentialStops": [
- {
- "id": "stop__9647199",
- "name": "Метро Войковская"
- },
- {
- "id": "stop__9639588",
- "name": "Коровинское шоссе"
- }
- ],
- "BriefSchedule": {
- "Events": [
- {
- "Estimated": {
- "value": "1570972913",
- "tzOffset": 10800,
- "text": "16:21"
- },
- "vehicleId": "codd%5Fnew|1092230%5F191422"
- }
- ],
- "Frequency": {
- "text": "15 мин",
- "value": 900,
- "begin": {
- "value": "1570936205",
- "tzOffset": 10800,
- "text": "6:10"
- },
- "end": {
- "value": "1571004965",
- "tzOffset": 10800,
- "text": "1:16"
- }
- }
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=213_114_bus_mosgortrans&ll=37.508487%2C55.852137&name=114&r=3544&type=bus",
- "seoname": "114"
- },
- {
- "lineId": "213_179_bus_mosgortrans",
- "name": "179",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "213B_179_bus_mosgortrans",
- "EssentialStops": [
- {
- "id": "stop__9647199",
- "name": "Метро Войковская"
- },
- {
- "id": "stop__9639480",
- "name": "Платформа Лианозово"
- }
- ],
- "BriefSchedule": {
- "Events": [
- {
- "Estimated": {
- "value": "1570971963",
- "tzOffset": 10800,
- "text": "16:06"
- },
- "vehicleId": "codd%5Fnew|194519%5F31367"
- },
- {
- "Estimated": {
- "value": "1570973105",
- "tzOffset": 10800,
- "text": "16:25"
- },
- "vehicleId": "codd%5Fnew|56358%5F31365"
- }
- ],
- "Frequency": {
- "text": "15 мин",
- "value": 900,
- "begin": {
- "value": "1570936823",
- "tzOffset": 10800,
- "text": "6:20"
- },
- "end": {
- "value": "1571005583",
- "tzOffset": 10800,
- "text": "1:26"
- }
- }
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=213_179_bus_mosgortrans&ll=37.526151%2C55.858031&name=179&r=4634&type=bus",
- "seoname": "179"
- },
- {
- "lineId": "213_191m_minibus_default",
- "name": "591",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "213A_191m_minibus_default",
- "EssentialStops": [
- {
- "id": "stop__9647199",
- "name": "Метро Войковская"
- },
- {
- "id": "stop__9711744",
- "name": "Станция Ховрино"
- }
- ],
- "BriefSchedule": {
- "Events": [
- {
- "Estimated": {
- "value": "1570972150",
- "tzOffset": 10800,
- "text": "16:09"
- },
- "vehicleId": "codd%5Fnew|35595%5F9345307"
- }
- ],
- "Frequency": {
- "text": "22 мин",
- "value": 1320,
- "begin": {
- "value": "1570934833",
- "tzOffset": 10800,
- "text": "5:47"
- },
- "end": {
- "value": "1571005033",
- "tzOffset": 10800,
- "text": "1:17"
- }
- }
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=213_191m_minibus_default&ll=37.510906%2C55.848214&name=591&r=3384&type=bus",
- "seoname": "591"
- },
- {
- "lineId": "213_206m_minibus_default",
- "name": "206к",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "213A_206m_minibus_default",
- "EssentialStops": [
- {
- "id": "stop__9640756",
- "name": "Метро Петровско-Разумовская"
- },
- {
- "id": "stop__9640553",
- "name": "Лобненская улица"
- }
- ],
- "BriefSchedule": {
- "Events": [],
- "Frequency": {
- "text": "22 мин",
- "value": 1320,
- "begin": {
- "value": "1570934039",
- "tzOffset": 10800,
- "text": "5:33"
- },
- "end": {
- "value": "1571004239",
- "tzOffset": 10800,
- "text": "1:03"
- }
- }
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=213_206m_minibus_default&ll=37.548997%2C55.864997&name=206%D0%BA&r=3515&type=bus",
- "seoname": "206k"
- },
- {
- "lineId": "213_215_bus_mosgortrans",
- "name": "215",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "213B_215_bus_mosgortrans",
- "EssentialStops": [
- {
- "id": "stop__9711780",
- "name": "Метро Петровско-Разумовская"
- },
- {
- "id": "stop__9711744",
- "name": "Станция Ховрино"
- }
- ],
- "BriefSchedule": {
- "Events": [],
- "Frequency": {
- "text": "27 мин",
- "value": 1620,
- "begin": {
- "value": "1570934076",
- "tzOffset": 10800,
- "text": "5:34"
- },
- "end": {
- "value": "1571004276",
- "tzOffset": 10800,
- "text": "1:04"
- }
- }
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=213_215_bus_mosgortrans&ll=37.543701%2C55.854527&name=215&r=2763&type=bus",
- "seoname": "215"
- },
- {
- "lineId": "213_36_trolleybus_mosgortrans",
- "name": "т36",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "213A_36_trolleybus_mosgortrans",
- "EssentialStops": [
- {
- "id": "stop__9642550",
- "name": "ВДНХ (южная)"
- },
- {
- "id": "stop__9640641",
- "name": "Дмитровское шоссе, 155"
- }
- ],
- "BriefSchedule": {
- "Events": [
- {
- "Estimated": {
- "value": "1570972236",
- "tzOffset": 10800,
- "text": "16:10"
- },
- "vehicleId": "codd%5Fnew|1084830%5F430261"
- },
- {
- "Estimated": {
- "value": "1570972641",
- "tzOffset": 10800,
- "text": "16:17"
- },
- "vehicleId": "codd%5Fnew|1084829%5F430260"
- },
- {
- "Estimated": {
- "value": "1570973178",
- "tzOffset": 10800,
- "text": "16:26"
- },
- "vehicleId": "codd%5Fnew|1084827%5F430255"
- }
- ],
- "Frequency": {
- "text": "12 мин",
- "value": 720,
- "begin": {
- "value": "1570932741",
- "tzOffset": 10800,
- "text": "5:12"
- },
- "end": {
- "value": "1571003121",
- "tzOffset": 10800,
- "text": "0:45"
- }
- }
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=213_36_trolleybus_mosgortrans&ll=37.588604%2C55.859705&name=%D1%8236&r=5104&type=bus",
- "seoname": "t36"
- },
- {
- "lineId": "213_47_trolleybus_mosgortrans",
- "name": "т47",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "213B_47_trolleybus_mosgortrans",
- "EssentialStops": [
- {
- "id": "stop__9639568",
- "name": "Бескудниковский переулок"
- },
- {
- "id": "stop__9641903",
- "name": "Бескудниковский переулок"
- }
- ],
- "BriefSchedule": {
- "Events": [
- {
- "Scheduled": {
- "value": "1570972080",
- "tzOffset": 10800,
- "text": "16:08"
- },
- "Estimated": {
- "value": "1570972183",
- "tzOffset": 10800,
- "text": "16:09"
- },
- "vehicleId": "codd%5Fnew|1132404%5F430361"
- },
- {
- "Scheduled": {
- "value": "1570972980",
- "tzOffset": 10800,
- "text": "16:23"
- },
- "Estimated": {
- "value": "1570972219",
- "tzOffset": 10800,
- "text": "16:10"
- },
- "vehicleId": "codd%5Fnew|1136132%5F430358"
- },
- {
- "Scheduled": {
- "value": "1570973940",
- "tzOffset": 10800,
- "text": "16:39"
- }
- }
- ],
- "departureTime": "16:08"
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=213_47_trolleybus_mosgortrans&ll=37.588308%2C55.818685&name=%D1%8247&r=5359&type=bus",
- "seoname": "t47"
- },
- {
- "lineId": "213_56_trolleybus_mosgortrans",
- "name": "т56",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "213A_56_trolleybus_mosgortrans",
- "EssentialStops": [
- {
- "id": "stop__9639561",
- "name": "Коровинское шоссе"
- },
- {
- "id": "stop__9639588",
- "name": "Коровинское шоссе"
- }
- ],
- "BriefSchedule": {
- "Events": [
- {
- "Scheduled": {
- "value": "1570971900",
- "tzOffset": 10800,
- "text": "16:05"
- },
- "Estimated": {
- "value": "1570972560",
- "tzOffset": 10800,
- "text": "16:16"
- },
- "vehicleId": "codd%5Fnew|1117148%5F430351"
- },
- {
- "Scheduled": {
- "value": "1570972680",
- "tzOffset": 10800,
- "text": "16:18"
- },
- "Estimated": {
- "value": "1570973442",
- "tzOffset": 10800,
- "text": "16:30"
- },
- "vehicleId": "codd%5Fnew|1080552%5F430302"
- },
- {
- "Scheduled": {
- "value": "1570973400",
- "tzOffset": 10800,
- "text": "16:30"
- }
- }
- ],
- "departureTime": "16:05"
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=213_56_trolleybus_mosgortrans&ll=37.551454%2C55.830147&name=%D1%8256&r=6304&type=bus",
- "seoname": "t56"
- },
- {
- "lineId": "213_63_bus_mosgortrans",
- "name": "63",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "213A_63_bus_mosgortrans",
- "EssentialStops": [
- {
- "id": "stop__9640554",
- "name": "Лобненская улица"
- },
- {
- "id": "stop__9640553",
- "name": "Лобненская улица"
- }
- ],
- "BriefSchedule": {
- "Events": [
- {
- "Estimated": {
- "value": "1570972434",
- "tzOffset": 10800,
- "text": "16:13"
- },
- "vehicleId": "codd%5Fnew|38700%5F9215301"
- }
- ],
- "Frequency": {
- "text": "17 мин",
- "value": 1020,
- "begin": {
- "value": "1570934207",
- "tzOffset": 10800,
- "text": "5:36"
- },
- "end": {
- "value": "1571003507",
- "tzOffset": 10800,
- "text": "0:51"
- }
- }
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=213_63_bus_mosgortrans&ll=37.550792%2C55.872690&name=63&r=3057&type=bus",
- "seoname": "63"
- },
- {
- "lineId": "213_677_bus_mosgortrans",
- "name": "677",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "213B_677_bus_mosgortrans",
- "EssentialStops": [
- {
- "id": "stop__9639495",
- "name": "Метро Петровско-Разумовская"
- },
- {
- "id": "stop__9639480",
- "name": "Платформа Лианозово"
- }
- ],
- "BriefSchedule": {
- "Events": [
- {
- "Scheduled": {
- "value": "1570972200",
- "tzOffset": 10800,
- "text": "16:10"
- },
- "Estimated": {
- "value": "1570971838",
- "tzOffset": 10800,
- "text": "16:03"
- },
- "vehicleId": "codd%5Fnew|58581%5F31321"
- },
- {
- "Scheduled": {
- "value": "1570972560",
- "tzOffset": 10800,
- "text": "16:16"
- }
- },
- {
- "Scheduled": {
- "value": "1570972920",
- "tzOffset": 10800,
- "text": "16:22"
- }
- }
- ],
- "departureTime": "16:10"
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=213_677_bus_mosgortrans&ll=37.564191%2C55.866620&name=677&r=3386&type=bus",
- "seoname": "677"
- },
- {
- "lineId": "213_78_trolleybus_mosgortrans",
- "name": "т78",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "213A_78_trolleybus_mosgortrans",
- "EssentialStops": [
- {
- "id": "stop__9887464",
- "name": "9-я Северная линия"
- },
- {
- "id": "stop__9887464",
- "name": "9-я Северная линия"
- }
- ],
- "BriefSchedule": {
- "Events": [
- {
- "Estimated": {
- "value": "1570971984",
- "tzOffset": 10800,
- "text": "16:06"
- },
- "vehicleId": "codd%5Fnew|59694%5F31155"
- },
- {
- "Estimated": {
- "value": "1570972003",
- "tzOffset": 10800,
- "text": "16:06"
- },
- "vehicleId": "codd%5Fnew|55041%5F31116"
- },
- {
- "Estimated": {
- "value": "1570972550",
- "tzOffset": 10800,
- "text": "16:15"
- },
- "vehicleId": "codd%5Fnew|62710%5F31142"
- },
- {
- "Estimated": {
- "value": "1570973307",
- "tzOffset": 10800,
- "text": "16:28"
- },
- "vehicleId": "codd%5Fnew|1037437%5F31144"
- },
- {
- "Estimated": {
- "value": "1570973456",
- "tzOffset": 10800,
- "text": "16:30"
- },
- "vehicleId": "codd%5Fnew|318517%5F31136"
- }
- ],
- "Frequency": {
- "text": "11 мин",
- "value": 660,
- "begin": {
- "value": "1570937045",
- "tzOffset": 10800,
- "text": "6:24"
- },
- "end": {
- "value": "1571002385",
- "tzOffset": 10800,
- "text": "0:33"
- }
- }
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=213_78_trolleybus_mosgortrans&ll=37.569453%2C55.855402&name=%D1%8278&r=8810&type=bus",
- "seoname": "t78"
- },
- {
- "lineId": "2465131598",
- "name": "179к",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "2465131758",
- "EssentialStops": [
- {
- "id": "stop__9640244",
- "name": "Платформа Лианозово"
- },
- {
- "id": "stop__9639480",
- "name": "Платформа Лианозово"
- }
- ],
- "BriefSchedule": {
- "Events": [],
- "Frequency": {
- "text": "15 мин",
- "value": 900,
- "begin": {
- "value": "1570935230",
- "tzOffset": 10800,
- "text": "5:53"
- },
- "end": {
- "value": "1571003030",
- "tzOffset": 10800,
- "text": "0:43"
- }
- }
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=2465131598&ll=37.561423%2C55.871807&name=179%D0%BA&r=2787&type=bus",
- "seoname": "179k"
- },
- {
- "lineId": "677k_bus_default",
- "name": "677к",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "677kA_bus_default",
- "EssentialStops": [
- {
- "id": "stop__9640244",
- "name": "Платформа Лианозово"
- },
- {
- "id": "stop__9639480",
- "name": "Платформа Лианозово"
- }
- ],
- "BriefSchedule": {
- "Events": [
- {
- "Scheduled": {
- "value": "1570972560",
- "tzOffset": 10800,
- "text": "16:16"
- },
- "Estimated": {
- "value": "1570971986",
- "tzOffset": 10800,
- "text": "16:06"
- },
- "vehicleId": "codd%5Fnew|1038096%5F31398"
- },
- {
- "Scheduled": {
- "value": "1570973280",
- "tzOffset": 10800,
- "text": "16:28"
- },
- "Estimated": {
- "value": "1570972342",
- "tzOffset": 10800,
- "text": "16:12"
- },
- "vehicleId": "codd%5Fnew|58590%5F31348"
- },
- {
- "Scheduled": {
- "value": "1570974000",
- "tzOffset": 10800,
- "text": "16:40"
- },
- "Estimated": {
- "value": "1570973387",
- "tzOffset": 10800,
- "text": "16:29"
- },
- "vehicleId": "codd%5Fnew|58902%5F31316"
- }
- ],
- "departureTime": "16:16"
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=677k_bus_default&ll=37.565257%2C55.870397&name=677%D0%BA&r=2987&type=bus",
- "seoname": "677k"
- },
- {
- "lineId": "m10_bus_default",
- "name": "м10",
- "Types": [
- "bus"
- ],
- "type": "bus",
- "threads": [
- {
- "threadId": "2036926048",
- "EssentialStops": [
- {
- "id": "stop__9640554",
- "name": "Лобненская улица"
- },
- {
- "id": "stop__9640553",
- "name": "Лобненская улица"
- }
- ],
- "BriefSchedule": {
- "Events": [
- {
- "Estimated": {
- "value": "1570972343",
- "tzOffset": 10800,
- "text": "16:12"
- },
- "vehicleId": "codd%5Fnew|62922%5F31434"
- },
- {
- "Estimated": {
- "value": "1570972813",
- "tzOffset": 10800,
- "text": "16:20"
- },
- "vehicleId": "codd%5Fnew|57281%5F31242"
- }
- ],
- "Frequency": {
- "text": "15 мин",
- "value": 900,
- "begin": {
- "value": "1570939772",
- "tzOffset": 10800,
- "text": "7:09"
- },
- "end": {
- "value": "1571008052",
- "tzOffset": 10800,
- "text": "2:07"
- }
- }
- }
- }
- ],
- "uri": "ymapsbm1://transit/line?id=m10_bus_default&ll=37.579221%2C55.823763&name=%D0%BC10&r=8474&type=bus",
- "seoname": "m10"
+ ],
+ "departureTime": "18:20"
+ }
}
- ]
+ ],
+ "uri": "ymapsbm1://transit/line?id=2036924720&ll=37.577436%2C55.828981&name=692&r=4037&type=bus",
+ "seoname": "692"
+ },
+ {
+ "lineId": "2036924957",
+ "name": "т29",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "2036925191",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9642124",
+ "name": "Метро Тимирязевская"
+ },
+ {
+ "id": "stop__10007273",
+ "name": "Метро Тимирязевская"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Scheduled": {
+ "value": "1583422260",
+ "tzOffset": 10800,
+ "text": "18:31"
+ },
+ "Estimated": {
+ "value": "1583421899",
+ "tzOffset": 10800,
+ "text": "18:24"
+ },
+ "vehicleId": "codd%5Fnew|57882%5F31270"
+ },
+ {
+ "Scheduled": {
+ "value": "1583423160",
+ "tzOffset": 10800,
+ "text": "18:46"
+ },
+ "Estimated": {
+ "value": "1583422201",
+ "tzOffset": 10800,
+ "text": "18:30"
+ },
+ "vehicleId": "codd%5Fnew|58538%5F31266"
+ },
+ {
+ "Scheduled": {
+ "value": "1583424060",
+ "tzOffset": 10800,
+ "text": "19:01"
+ },
+ "Estimated": {
+ "value": "1583422779",
+ "tzOffset": 10800,
+ "text": "18:39"
+ },
+ "vehicleId": "codd%5Fnew|58626%5F31209"
+ },
+ {
+ "Estimated": {
+ "value": "1583423238",
+ "tzOffset": 10800,
+ "text": "18:47"
+ },
+ "vehicleId": "codd%5Fnew|58577%5F31224"
+ }
+ ],
+ "departureTime": "18:31"
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=2036924957&ll=37.566536%2C55.821320&name=%D1%8229&r=3614&type=bus",
+ "seoname": "t29"
+ },
+ {
+ "lineId": "2036924959",
+ "name": "т3",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "2036928125",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9644805",
+ "name": "7-й автобусный парк"
+ },
+ {
+ "id": "2310890052",
+ "name": "Метро Верхние Лихоборы"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Scheduled": {
+ "value": "1583421780",
+ "tzOffset": 10800,
+ "text": "18:23"
+ },
+ "Estimated": {
+ "value": "1583422190",
+ "tzOffset": 10800,
+ "text": "18:29"
+ },
+ "vehicleId": "codd%5Fnew|58308%5F31268"
+ },
+ {
+ "Scheduled": {
+ "value": "1583422380",
+ "tzOffset": 10800,
+ "text": "18:33"
+ },
+ "Estimated": {
+ "value": "1583422522",
+ "tzOffset": 10800,
+ "text": "18:35"
+ },
+ "vehicleId": "codd%5Fnew|60817%5F31226"
+ },
+ {
+ "Scheduled": {
+ "value": "1583423040",
+ "tzOffset": 10800,
+ "text": "18:44"
+ },
+ "Estimated": {
+ "value": "1583423156",
+ "tzOffset": 10800,
+ "text": "18:45"
+ },
+ "vehicleId": "codd%5Fnew|146304%5F31207"
+ }
+ ],
+ "departureTime": "18:23"
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=2036924959&ll=37.582485%2C55.812619&name=%D1%823&r=4734&type=bus",
+ "seoname": "t3"
+ },
+ {
+ "lineId": "2036924968",
+ "name": "82",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "2036925244",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9712356",
+ "name": "Станция Дегунино"
+ },
+ {
+ "id": "3163417967",
+ "name": "Станция Дегунино"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Scheduled": {
+ "value": "1583423340",
+ "tzOffset": 10800,
+ "text": "18:49"
+ },
+ "Estimated": {
+ "value": "1583421564",
+ "tzOffset": 10800,
+ "text": "18:19"
+ },
+ "vehicleId": "codd%5Fnew|58855%5F31459"
+ },
+ {
+ "Scheduled": {
+ "value": "1583425380",
+ "tzOffset": 10800,
+ "text": "19:23"
+ }
+ },
+ {
+ "Scheduled": {
+ "value": "1583427060",
+ "tzOffset": 10800,
+ "text": "19:51"
+ }
+ }
+ ],
+ "departureTime": "18:49"
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=2036924968&ll=37.576010%2C55.822319&name=82&r=4771&type=bus",
+ "seoname": "82"
+ },
+ {
+ "lineId": "2036925396",
+ "name": "м10",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "2036926048",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9640554",
+ "name": "Лобненская улица"
+ },
+ {
+ "id": "stop__9640553",
+ "name": "Лобненская улица"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Scheduled": {
+ "value": "1583421720",
+ "tzOffset": 10800,
+ "text": "18:22"
+ },
+ "Estimated": {
+ "value": "1583421638",
+ "tzOffset": 10800,
+ "text": "18:20"
+ },
+ "vehicleId": "codd%5Fnew|58857%5F31208"
+ },
+ {
+ "Scheduled": {
+ "value": "1583422320",
+ "tzOffset": 10800,
+ "text": "18:32"
+ },
+ "Estimated": {
+ "value": "1583422060",
+ "tzOffset": 10800,
+ "text": "18:27"
+ },
+ "vehicleId": "codd%5Fnew|146255%5F31230"
+ },
+ {
+ "Scheduled": {
+ "value": "1583422800",
+ "tzOffset": 10800,
+ "text": "18:40"
+ },
+ "Estimated": {
+ "value": "1583422172",
+ "tzOffset": 10800,
+ "text": "18:29"
+ },
+ "vehicleId": "codd%5Fnew|62727%5F31251"
+ },
+ {
+ "Estimated": {
+ "value": "1583422871",
+ "tzOffset": 10800,
+ "text": "18:41"
+ },
+ "vehicleId": "codd%5Fnew|58861%5F31225"
+ },
+ {
+ "Estimated": {
+ "value": "1583423033",
+ "tzOffset": 10800,
+ "text": "18:43"
+ },
+ "vehicleId": "codd%5Fnew|58892%5F31202"
+ }
+ ],
+ "departureTime": "18:22"
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=2036925396&ll=37.579221%2C55.823763&name=%D0%BC10&r=8474&type=bus",
+ "seoname": "m10"
+ },
+ {
+ "lineId": "2036925416",
+ "name": "194",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "2036927196",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9711780",
+ "name": "Метро Петровско-Разумовская"
+ },
+ {
+ "id": "stop__9648742",
+ "name": "Коровино"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Scheduled": {
+ "value": "1583422500",
+ "tzOffset": 10800,
+ "text": "18:35"
+ },
+ "Estimated": {
+ "value": "1583421540",
+ "tzOffset": 10800,
+ "text": "18:19"
+ },
+ "vehicleId": "codd%5Fnew|1101580%5F191636"
+ },
+ {
+ "Scheduled": {
+ "value": "1583423460",
+ "tzOffset": 10800,
+ "text": "18:51"
+ }
+ },
+ {
+ "Scheduled": {
+ "value": "1583424360",
+ "tzOffset": 10800,
+ "text": "19:06"
+ }
+ }
+ ],
+ "departureTime": "18:35"
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=2036925416&ll=37.545611%2C55.865605&name=194&r=3662&type=bus",
+ "seoname": "194"
+ },
+ {
+ "lineId": "2036925728",
+ "name": "282",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "213A_282_bus_mosgortrans",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9641102",
+ "name": "Улица Корнейчука"
+ },
+ {
+ "id": "2532226085",
+ "name": "Метро Войковская"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Estimated": {
+ "value": "1583421540",
+ "tzOffset": 10800,
+ "text": "18:19"
+ },
+ "vehicleId": "codd%5Fnew|37916%5F9225416"
+ },
+ {
+ "Estimated": {
+ "value": "1583422093",
+ "tzOffset": 10800,
+ "text": "18:28"
+ },
+ "vehicleId": "codd%5Fnew|34861%5F9345407"
+ },
+ {
+ "Estimated": {
+ "value": "1583422486",
+ "tzOffset": 10800,
+ "text": "18:34"
+ },
+ "vehicleId": "codd%5Fnew|34847%5F9545405"
+ },
+ {
+ "Estimated": {
+ "value": "1583423061",
+ "tzOffset": 10800,
+ "text": "18:44"
+ },
+ "vehicleId": "codd%5Fnew|34857%5F9345402"
+ }
+ ],
+ "Frequency": {
+ "text": "15 мин",
+ "value": 900,
+ "begin": {
+ "value": "1583376863",
+ "tzOffset": 10800,
+ "text": "5:54"
+ },
+ "end": {
+ "value": "1583448143",
+ "tzOffset": 10800,
+ "text": "1:42"
+ }
+ }
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=2036925728&ll=37.553526%2C55.860385&name=282&r=5779&type=bus",
+ "seoname": "282"
+ },
+ {
+ "lineId": "2036926781",
+ "name": "154",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "213B_154_bus_mosgortrans",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9642548",
+ "name": "ВДНХ (южная)"
+ },
+ {
+ "id": "stop__9711744",
+ "name": "Станция Ховрино"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Scheduled": {
+ "value": "1583422260",
+ "tzOffset": 10800,
+ "text": "18:31"
+ },
+ "Estimated": {
+ "value": "1583422184",
+ "tzOffset": 10800,
+ "text": "18:29"
+ },
+ "vehicleId": "codd%5Fnew|1092234%5F191522"
+ },
+ {
+ "Scheduled": {
+ "value": "1583423520",
+ "tzOffset": 10800,
+ "text": "18:52"
+ },
+ "Estimated": {
+ "value": "1583422778",
+ "tzOffset": 10800,
+ "text": "18:39"
+ },
+ "vehicleId": "codd%5Fnew|62451%5F190582"
+ },
+ {
+ "Scheduled": {
+ "value": "1583424660",
+ "tzOffset": 10800,
+ "text": "19:11"
+ }
+ }
+ ],
+ "departureTime": "18:31"
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=2036926781&ll=37.576273%2C55.846412&name=154&r=4918&type=bus",
+ "seoname": "154"
+ },
+ {
+ "lineId": "2036926818",
+ "name": "994",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "2036925175",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9640756",
+ "name": "Метро Петровско-Разумовская"
+ },
+ {
+ "id": "stop__9640351",
+ "name": "Метро Петровско-Разумовская"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Estimated": {
+ "value": "1583421928",
+ "tzOffset": 10800,
+ "text": "18:25"
+ },
+ "vehicleId": "codd%5Fnew|45432%5F9745639"
+ }
+ ],
+ "Frequency": {
+ "text": "30 мин",
+ "value": 1800,
+ "begin": {
+ "value": "1583375749",
+ "tzOffset": 10800,
+ "text": "5:35"
+ },
+ "end": {
+ "value": "1583445949",
+ "tzOffset": 10800,
+ "text": "1:05"
+ }
+ }
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=2036926818&ll=37.563627%2C55.871273&name=994&r=3902&type=bus",
+ "seoname": "994"
+ },
+ {
+ "lineId": "2036926890",
+ "name": "466",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "466B_bus_default",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9640546",
+ "name": "Станция Бескудниково"
+ },
+ {
+ "id": "stop__9640545",
+ "name": "Станция Бескудниково"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Estimated": {
+ "value": "1583421843",
+ "tzOffset": 10800,
+ "text": "18:24"
+ },
+ "vehicleId": "codd%5Fnew|41011%5F1018030"
+ },
+ {
+ "Estimated": {
+ "value": "1583422338",
+ "tzOffset": 10800,
+ "text": "18:32"
+ },
+ "vehicleId": "codd%5Fnew|41008%5F9715027"
+ },
+ {
+ "Estimated": {
+ "value": "1583423283",
+ "tzOffset": 10800,
+ "text": "18:48"
+ },
+ "vehicleId": "codd%5Fnew|41857%5F9705058"
+ }
+ ],
+ "Frequency": {
+ "text": "22 мин",
+ "value": 1320,
+ "begin": {
+ "value": "1583379047",
+ "tzOffset": 10800,
+ "text": "6:30"
+ },
+ "end": {
+ "value": "1583449847",
+ "tzOffset": 10800,
+ "text": "2:10"
+ }
+ }
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=2036926890&ll=37.564238%2C55.845050&name=466&r=4163&type=bus",
+ "seoname": "466"
+ },
+ {
+ "lineId": "213_114_bus_mosgortrans",
+ "name": "114",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "213B_114_bus_mosgortrans",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9647199",
+ "name": "Метро Войковская"
+ },
+ {
+ "id": "stop__9639588",
+ "name": "Коровинское шоссе"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Scheduled": {
+ "value": "1583422200",
+ "tzOffset": 10800,
+ "text": "18:30"
+ },
+ "Estimated": {
+ "value": "1583422179",
+ "tzOffset": 10800,
+ "text": "18:29"
+ },
+ "vehicleId": "codd%5Fnew|1092236%5F191423"
+ },
+ {
+ "Scheduled": {
+ "value": "1583423340",
+ "tzOffset": 10800,
+ "text": "18:49"
+ },
+ "Estimated": {
+ "value": "1583423191",
+ "tzOffset": 10800,
+ "text": "18:46"
+ },
+ "vehicleId": "codd%5Fnew|1054181%5F191402"
+ },
+ {
+ "Scheduled": {
+ "value": "1583424420",
+ "tzOffset": 10800,
+ "text": "19:07"
+ }
+ }
+ ],
+ "departureTime": "18:30"
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=213_114_bus_mosgortrans&ll=37.508504%2C55.852139&name=114&r=3544&type=bus",
+ "seoname": "114"
+ },
+ {
+ "lineId": "213_179_bus_mosgortrans",
+ "name": "179",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "213B_179_bus_mosgortrans",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9647199",
+ "name": "Метро Войковская"
+ },
+ {
+ "id": "stop__9639480",
+ "name": "Станция Лианозово"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Scheduled": {
+ "value": "1583422020",
+ "tzOffset": 10800,
+ "text": "18:27"
+ },
+ "Estimated": {
+ "value": "1583421975",
+ "tzOffset": 10800,
+ "text": "18:26"
+ },
+ "vehicleId": "codd%5Fnew|58590%5F31348"
+ },
+ {
+ "Scheduled": {
+ "value": "1583422680",
+ "tzOffset": 10800,
+ "text": "18:38"
+ },
+ "Estimated": {
+ "value": "1583422480",
+ "tzOffset": 10800,
+ "text": "18:34"
+ },
+ "vehicleId": "codd%5Fnew|60470%5F31355"
+ },
+ {
+ "Scheduled": {
+ "value": "1583423340",
+ "tzOffset": 10800,
+ "text": "18:49"
+ },
+ "Estimated": {
+ "value": "1583423264",
+ "tzOffset": 10800,
+ "text": "18:47"
+ },
+ "vehicleId": "codd%5Fnew|1115928%5F31333"
+ }
+ ],
+ "departureTime": "18:27"
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=213_179_bus_mosgortrans&ll=37.526151%2C55.858031&name=179&r=4634&type=bus",
+ "seoname": "179"
+ },
+ {
+ "lineId": "213_191m_minibus_default",
+ "name": "591",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "213A_191m_minibus_default",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9647199",
+ "name": "Метро Войковская"
+ },
+ {
+ "id": "stop__9711744",
+ "name": "Станция Ховрино"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Estimated": {
+ "value": "1583421660",
+ "tzOffset": 10800,
+ "text": "18:21"
+ },
+ "vehicleId": "codd%5Fnew|35615%5F9345304"
+ },
+ {
+ "Estimated": {
+ "value": "1583422323",
+ "tzOffset": 10800,
+ "text": "18:32"
+ },
+ "vehicleId": "codd%5Fnew|35641%5F9345301"
+ },
+ {
+ "Estimated": {
+ "value": "1583422926",
+ "tzOffset": 10800,
+ "text": "18:42"
+ },
+ "vehicleId": "codd%5Fnew|38273%5F9345310"
+ }
+ ],
+ "Frequency": {
+ "text": "22 мин",
+ "value": 1320,
+ "begin": {
+ "value": "1583376433",
+ "tzOffset": 10800,
+ "text": "5:47"
+ },
+ "end": {
+ "value": "1583446633",
+ "tzOffset": 10800,
+ "text": "1:17"
+ }
+ }
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=213_191m_minibus_default&ll=37.510906%2C55.848214&name=591&r=3384&type=bus",
+ "seoname": "591"
+ },
+ {
+ "lineId": "213_36_trolleybus_mosgortrans",
+ "name": "т36",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "213A_36_trolleybus_mosgortrans",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9642550",
+ "name": "ВДНХ (южная)"
+ },
+ {
+ "id": "stop__9640641",
+ "name": "Дмитровское шоссе, 155"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Scheduled": {
+ "value": "1583421600",
+ "tzOffset": 10800,
+ "text": "18:20"
+ },
+ "Estimated": {
+ "value": "1583421929",
+ "tzOffset": 10800,
+ "text": "18:25"
+ },
+ "vehicleId": "codd%5Fnew|1105873%5F430273"
+ },
+ {
+ "Scheduled": {
+ "value": "1583422080",
+ "tzOffset": 10800,
+ "text": "18:28"
+ },
+ "Estimated": {
+ "value": "1583422942",
+ "tzOffset": 10800,
+ "text": "18:42"
+ },
+ "vehicleId": "codd%5Fnew|1084831%5F430257"
+ },
+ {
+ "Scheduled": {
+ "value": "1583422560",
+ "tzOffset": 10800,
+ "text": "18:36"
+ },
+ "Estimated": {
+ "value": "1583423178",
+ "tzOffset": 10800,
+ "text": "18:46"
+ },
+ "vehicleId": "codd%5Fnew|1042449%5F430223"
+ }
+ ],
+ "departureTime": "18:20"
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=213_36_trolleybus_mosgortrans&ll=37.588569%2C55.859718&name=%D1%8236&r=5106&type=bus",
+ "seoname": "t36"
+ },
+ {
+ "lineId": "213_47_trolleybus_mosgortrans",
+ "name": "т47",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "213B_47_trolleybus_mosgortrans",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9639568",
+ "name": "Бескудниковский переулок"
+ },
+ {
+ "id": "stop__9641903",
+ "name": "Бескудниковский переулок"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Scheduled": {
+ "value": "1583421840",
+ "tzOffset": 10800,
+ "text": "18:24"
+ },
+ "Estimated": {
+ "value": "1583422502",
+ "tzOffset": 10800,
+ "text": "18:35"
+ },
+ "vehicleId": "codd%5Fnew|1119832%5F430355"
+ },
+ {
+ "Scheduled": {
+ "value": "1583422200",
+ "tzOffset": 10800,
+ "text": "18:30"
+ },
+ "Estimated": {
+ "value": "1583422636",
+ "tzOffset": 10800,
+ "text": "18:37"
+ },
+ "vehicleId": "codd%5Fnew|1080551%5F430301"
+ },
+ {
+ "Scheduled": {
+ "value": "1583422560",
+ "tzOffset": 10800,
+ "text": "18:36"
+ },
+ "Estimated": {
+ "value": "1583422845",
+ "tzOffset": 10800,
+ "text": "18:40"
+ },
+ "vehicleId": "codd%5Fnew|1139254%5F430378"
+ },
+ {
+ "Estimated": {
+ "value": "1583422950",
+ "tzOffset": 10800,
+ "text": "18:42"
+ },
+ "vehicleId": "codd%5Fnew|1091992%5F430316"
+ }
+ ],
+ "departureTime": "18:24"
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=213_47_trolleybus_mosgortrans&ll=37.588308%2C55.818685&name=%D1%8247&r=5359&type=bus",
+ "seoname": "t47"
+ },
+ {
+ "lineId": "213_56_trolleybus_mosgortrans",
+ "name": "т56",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "213A_56_trolleybus_mosgortrans",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9639561",
+ "name": "Коровинское шоссе"
+ },
+ {
+ "id": "stop__9639588",
+ "name": "Коровинское шоссе"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Scheduled": {
+ "value": "1583421900",
+ "tzOffset": 10800,
+ "text": "18:25"
+ },
+ "Estimated": {
+ "value": "1583421746",
+ "tzOffset": 10800,
+ "text": "18:22"
+ },
+ "vehicleId": "codd%5Fnew|1117148%5F430351"
+ },
+ {
+ "Scheduled": {
+ "value": "1583422320",
+ "tzOffset": 10800,
+ "text": "18:32"
+ },
+ "Estimated": {
+ "value": "1583422183",
+ "tzOffset": 10800,
+ "text": "18:29"
+ },
+ "vehicleId": "codd%5Fnew|1139619%5F430381"
+ },
+ {
+ "Scheduled": {
+ "value": "1583422680",
+ "tzOffset": 10800,
+ "text": "18:38"
+ },
+ "Estimated": {
+ "value": "1583422496",
+ "tzOffset": 10800,
+ "text": "18:34"
+ },
+ "vehicleId": "codd%5Fnew|1119829%5F430352"
+ },
+ {
+ "Estimated": {
+ "value": "1583423315",
+ "tzOffset": 10800,
+ "text": "18:48"
+ },
+ "vehicleId": "codd%5Fnew|1139256%5F430373"
+ }
+ ],
+ "departureTime": "18:25"
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=213_56_trolleybus_mosgortrans&ll=37.551454%2C55.830147&name=%D1%8256&r=6304&type=bus",
+ "seoname": "t56"
+ },
+ {
+ "lineId": "213_677_bus_mosgortrans",
+ "name": "677",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "213B_677_bus_mosgortrans",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9639495",
+ "name": "Метро Петровско-Разумовская"
+ },
+ {
+ "id": "stop__9639480",
+ "name": "Станция Лианозово"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Scheduled": {
+ "value": "1583421660",
+ "tzOffset": 10800,
+ "text": "18:21"
+ },
+ "Estimated": {
+ "value": "1583421557",
+ "tzOffset": 10800,
+ "text": "18:19"
+ },
+ "vehicleId": "codd%5Fnew|1082793%5F31390"
+ },
+ {
+ "Scheduled": {
+ "value": "1583421840",
+ "tzOffset": 10800,
+ "text": "18:24"
+ },
+ "Estimated": {
+ "value": "1583421675",
+ "tzOffset": 10800,
+ "text": "18:21"
+ },
+ "vehicleId": "codd%5Fnew|58601%5F31323"
+ },
+ {
+ "Scheduled": {
+ "value": "1583422020",
+ "tzOffset": 10800,
+ "text": "18:27"
+ }
+ }
+ ],
+ "departureTime": "18:21"
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=213_677_bus_mosgortrans&ll=37.564191%2C55.866620&name=677&r=3386&type=bus",
+ "seoname": "677"
+ },
+ {
+ "lineId": "213_78_trolleybus_mosgortrans",
+ "name": "т78",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "213A_78_trolleybus_mosgortrans",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9887464",
+ "name": "9-я Северная линия"
+ },
+ {
+ "id": "stop__9887464",
+ "name": "9-я Северная линия"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Scheduled": {
+ "value": "1583421780",
+ "tzOffset": 10800,
+ "text": "18:23"
+ },
+ "Estimated": {
+ "value": "1583421764",
+ "tzOffset": 10800,
+ "text": "18:22"
+ },
+ "vehicleId": "codd%5Fnew|58868%5F31131"
+ },
+ {
+ "Scheduled": {
+ "value": "1583422200",
+ "tzOffset": 10800,
+ "text": "18:30"
+ },
+ "Estimated": {
+ "value": "1583422361",
+ "tzOffset": 10800,
+ "text": "18:32"
+ },
+ "vehicleId": "codd%5Fnew|58373%5F31158"
+ },
+ {
+ "Scheduled": {
+ "value": "1583422620",
+ "tzOffset": 10800,
+ "text": "18:37"
+ },
+ "Estimated": {
+ "value": "1583422440",
+ "tzOffset": 10800,
+ "text": "18:34"
+ },
+ "vehicleId": "codd%5Fnew|55687%5F31120"
+ },
+ {
+ "Estimated": {
+ "value": "1583423316",
+ "tzOffset": 10800,
+ "text": "18:48"
+ },
+ "vehicleId": "codd%5Fnew|58107%5F31147"
+ }
+ ],
+ "departureTime": "18:23"
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=213_78_trolleybus_mosgortrans&ll=37.569472%2C55.856380&name=%D1%8278&r=8918&type=bus",
+ "seoname": "t78"
+ },
+ {
+ "lineId": "677k_bus_default",
+ "name": "677к",
+ "Types": [
+ "bus"
+ ],
+ "type": "bus",
+ "threads": [
+ {
+ "threadId": "677kA_bus_default",
+ "noBoarding": false,
+ "EssentialStops": [
+ {
+ "id": "stop__9640244",
+ "name": "Станция Лианозово"
+ },
+ {
+ "id": "stop__9639480",
+ "name": "Станция Лианозово"
+ }
+ ],
+ "BriefSchedule": {
+ "Events": [
+ {
+ "Scheduled": {
+ "value": "1583422500",
+ "tzOffset": 10800,
+ "text": "18:35"
+ },
+ "Estimated": {
+ "value": "1583421857",
+ "tzOffset": 10800,
+ "text": "18:24"
+ },
+ "vehicleId": "codd%5Fnew|59576%5F31317"
+ },
+ {
+ "Scheduled": {
+ "value": "1583422860",
+ "tzOffset": 10800,
+ "text": "18:41"
+ },
+ "Estimated": {
+ "value": "1583422383",
+ "tzOffset": 10800,
+ "text": "18:33"
+ },
+ "vehicleId": "codd%5Fnew|58524%5F31321"
+ },
+ {
+ "Scheduled": {
+ "value": "1583423220",
+ "tzOffset": 10800,
+ "text": "18:47"
+ },
+ "Estimated": {
+ "value": "1583422574",
+ "tzOffset": 10800,
+ "text": "18:36"
+ },
+ "vehicleId": "codd%5Fnew|125096%5F31369"
+ },
+ {
+ "Estimated": {
+ "value": "1583423150",
+ "tzOffset": 10800,
+ "text": "18:45"
+ },
+ "vehicleId": "codd%5Fnew|1038096%5F31398"
+ }
+ ],
+ "departureTime": "18:35"
+ }
+ }
+ ],
+ "uri": "ymapsbm1://transit/line?id=677k_bus_default&ll=37.565257%2C55.870397&name=677%D0%BA&r=2987&type=bus",
+ "seoname": "677k"
}
- },
+ ],
+ "breadcrumbs": [
+ {
+ "name": "Карты",
+ "type": "root",
+ "url": "https://yandex.ru/maps/"
+ },
+ {
+ "name": "Москва",
+ "type": "region",
+ "url": "https://yandex.ru/maps/213/moscow/",
+ "region": {
+ "center": [
+ 37.622504,
+ 55.753215
+ ],
+ "zoom": 10
+ }
+ },
+ {
+ "name": "Общественный транспорт",
+ "type": "masstransit-home",
+ "url": "https://yandex.ru/maps/213/moscow/transport/"
+ },
+ {
+ "name": "7-й автобусный парк",
+ "type": "search",
+ "url": "https://yandex.ru/maps/213/moscow/stops/stop__9639579/",
+ "currentPage": true
+ }
+ ],
"searchResult": {
- "requestId": "1570971868582853-530182592-man1-6817",
+ "type": "business",
+ "requestId": "1583421546337462-875775042-sas1-6586-sas-addrs-nmeta-new-8031",
+ "analyticsId": "1",
"title": "7-й автобусный парк",
"description": "Россия, Москва, Дмитровское шоссе",
"address": "Россия, Москва, Дмитровское шоссе",
@@ -1379,6 +1622,10 @@
37.56528,
55.85196
],
+ "displayCoordinates": [
+ 37.56528,
+ 55.85196
+ ],
"bounds": [
[
37.543123,
@@ -1389,16 +1636,15 @@
55.92488366
]
],
- "displayCoordinates": [
- 37.56528,
- 55.85196
- ],
+ "logId": "dHlwZT1iaXpmaW5kZXI7aWQ9MjM5MzY2OTUwNjU4",
+ "uri": "ymapsbm1://transit/stop?id=stop__9639579",
+ "id": "239366950658",
"metro": [
{
"id": "2244536395",
"name": "Верхние Лихоборы",
"distance": "510 м",
- "distanceValue": 509.265,
+ "distanceValue": 509.184,
"coordinates": [
37.56121218,
55.854501501
@@ -1447,7 +1693,7 @@
"id": "2310890052",
"name": "Метро Верхние Лихоборы",
"distance": "420 м",
- "distanceValue": 424.274,
+ "distanceValue": 424.302,
"coordinates": [
37.563047501,
55.853727589
@@ -1478,7 +1724,7 @@
},
{
"id": "stop__9639906",
- "name": "Платформа Окружная",
+ "name": "Станция Окружная",
"distance": "930 м",
"distanceValue": 926.144,
"coordinates": [
@@ -1488,59 +1734,26 @@
"type": "common"
}
],
- "logId": "dHlwZT1iaXpmaW5kZXI7aWQ9MjM5MzY2OTUwNjU4",
- "type": "business",
- "id": "239366950658",
- "shortTitle": "7-й автобусный парк",
- "additionalAddress": "",
- "fullAddress": "Россия, Москва, Дмитровское шоссе",
- "postalCode": "",
- "addressDetails": {
- "locality": "Москва",
- "street": "Дмитровское шоссе"
+ "panorama": {
+ "id": "1297826777_672185801_23_1574243990",
+ "direction": [
+ 32,
+ 10
+ ],
+ "point": {
+ "type": "Point",
+ "coordinates": [
+ 37.565167718,
+ 55.8518591059
+ ]
+ },
+ "preview": "https://avatars.mds.yandex.net/get-altay/1908863/2a00000169e52b1c1475fd4b51bae1f09966/%s",
+ "staticPreview": "https://static-pano.maps.yandex.ru/v1/?panoid=1297826777_672185801_23_1574243990&size=500,240&azimuth=32&tilt=10&signature=fiN27DzM02pZ3x3eVIodfiQQ8tFX2_KGNZocjzsdfnA="
},
- "categories": [
- {
- "name": "Остановка общественного транспорта",
- "class": "bus stop",
- "seoname": "public_transport_stop",
- "pluralName": "Остановки общественного транспорта",
- "id": "223677355200"
- }
- ],
+ "shortTitle": "7-й автобусный парк",
+ "fullAddress": "Россия, Москва, Дмитровское шоссе",
"status": "open",
"businessLinks": [],
- "businessProperties": {
- "geoproduct_poi_color": "#ABAEB3",
- "snippet_show_title": "short_title",
- "snippet_show_rating": "five_star_rating",
- "snippet_show_photo": "single_photo",
- "snippet_show_eta": "show_eta",
- "snippet_show_category": "single_category",
- "snippet_show_subline": [
- "no_subline"
- ],
- "snippet_show_geoproduct_offer": "show_geoproduct_offer",
- "snippet_show_bookmark": "show_bookmark",
- "detailview_show_claim_organization": "not_show_claim_organization",
- "detailview_show_reviews": "show_reviews",
- "detailview_show_add_photo_button": "show_add_photo_button",
- "detailview_show_taxi_button": "show_taxi_button",
- "sensitive": "1"
- },
- "seoname": "7_y_avtobusny_park",
- "geoId": 117015,
- "uri": "ymapsbm1://org?oid=239366950658",
- "uriList": [
- "ymapsbm1://org?oid=239366950658",
- "ymapsbm1://transit/stop?id=stop__9639579"
- ],
- "references": [
- {
- "id": "2036929560",
- "scope": "nyak"
- }
- ],
"ratingData": {
"ratingCount": 0,
"ratingValue": 0,
@@ -1553,8 +1766,113 @@
"href": "https://www.yandex.ru"
}
],
- "analyticsId": "1"
- },
- "toponymSeoname": "dmitrovskoye_shosse"
+ "categories": [
+ {
+ "name": "Остановка общественного транспорта",
+ "class": "bus stop",
+ "seoname": "public_transport_stop",
+ "pluralName": "Остановки общественного транспорта"
+ }
+ ],
+ "businessProperties": {
+ "has_verified_owner": false,
+ "geoproduct_poi_color": "#ABAEB3",
+ "snippet_show_photo": "single_photo",
+ "snippet_show_subline": [
+ "no_subline"
+ ],
+ "unusual_hours": [
+ "2020-03-08"
+ ]
+ },
+ "seoname": "7_y_avtobusny_park",
+ "geoId": 117015,
+ "references": [
+ {
+ "id": "2036929560",
+ "scope": "nyak"
+ }
+ ],
+ "region": {
+ "id": 213,
+ "hierarchy": [
+ 225,
+ 1,
+ 213
+ ],
+ "seoname": "moscow",
+ "bounds": [
+ [
+ 37.0402925,
+ 55.31141404514547
+ ],
+ [
+ 38.2047155,
+ 56.190068045145466
+ ]
+ ],
+ "names": {
+ "ablative": "",
+ "accusative": "Москву",
+ "dative": "Москве",
+ "directional": "",
+ "genitive": "Москвы",
+ "instrumental": "Москвой",
+ "locative": "",
+ "nominative": "Москва",
+ "preposition": "в",
+ "prepositional": "Москве"
+ },
+ "longitude": 37.622504,
+ "latitude": 55.753215,
+ "zoom": 10
+ },
+ "breadcrumbs": [
+ {
+ "name": "Карты",
+ "type": "root",
+ "url": "https://yandex.ru/maps/"
+ },
+ {
+ "name": "Москва",
+ "type": "region",
+ "url": "https://yandex.ru/maps/213/moscow/",
+ "region": {
+ "center": [
+ 37.622504,
+ 55.753215
+ ],
+ "zoom": 10
+ }
+ },
+ {
+ "category": {
+ "name": "Остановка общественного транспорта",
+ "class": "bus stop",
+ "seoname": "public_transport_stop",
+ "pluralName": "Остановки общественного транспорта"
+ },
+ "name": "Остановки общественного транспорта",
+ "type": "category",
+ "url": "https://yandex.ru/maps/213/moscow/category/public_transport_stop/"
+ },
+ {
+ "name": "7-й автобусный парк",
+ "type": "search",
+ "url": "https://yandex.ru/maps/org/7_y_avtobusny_park/239366950658/",
+ "currentPage": true
+ }
+ ],
+ "geoWhere": {
+ "id": "10049405",
+ "seoname": "dmitrovskoye_shosse",
+ "kind": "street",
+ "coordinates": [
+ 37.547719,
+ 55.87593
+ ],
+ "encodedCoordinates": "Z04YcwNnTkQOQFtvfXR2dHVgZA=="
+ }
+ }
}
-}
\ No newline at end of file
+}
diff --git a/tests/helpers/test_collection.py b/tests/helpers/test_collection.py
index 1cc600c3f01..f2224858bb5 100644
--- a/tests/helpers/test_collection.py
+++ b/tests/helpers/test_collection.py
@@ -133,7 +133,11 @@ async def test_yaml_collection():
"mock-3",
{"id": "mock-3", "name": "Mock 3"},
)
- assert changes[4] == (collection.CHANGE_REMOVED, "mock-2", None,)
+ assert changes[4] == (
+ collection.CHANGE_REMOVED,
+ "mock-2",
+ {"id": "mock-2", "name": "Mock 2"},
+ )
async def test_yaml_collection_skipping_duplicate_ids():
@@ -370,4 +374,12 @@ async def test_storage_collection_websocket(hass, hass_ws_client):
assert response["success"]
assert len(changes) == 3
- assert changes[2] == (collection.CHANGE_REMOVED, "initial_name", None)
+ assert changes[2] == (
+ collection.CHANGE_REMOVED,
+ "initial_name",
+ {
+ "id": "initial_name",
+ "immutable_string": "no-changes",
+ "name": "Updated name",
+ },
+ )
diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py
index 9b6aa6b812d..ff269d2b8c6 100644
--- a/tests/helpers/test_config_validation.py
+++ b/tests/helpers/test_config_validation.py
@@ -464,7 +464,7 @@ def test_time():
def test_datetime():
"""Test date time validation."""
schema = vol.Schema(cv.datetime)
- for value in [date.today(), "Wrong DateTime", "2016-11-23"]:
+ for value in [date.today(), "Wrong DateTime"]:
with pytest.raises(vol.MultipleInvalid):
schema(value)
@@ -987,3 +987,61 @@ def test_uuid4_hex(caplog):
_hex = uuid.uuid4().hex
assert schema(_hex) == _hex
assert schema(_hex.upper()) == _hex
+
+
+def test_key_value_schemas():
+ """Test key value schemas."""
+ schema = vol.Schema(
+ cv.key_value_schemas(
+ "mode",
+ {
+ "number": vol.Schema({"mode": "number", "data": int}),
+ "string": vol.Schema({"mode": "string", "data": str}),
+ },
+ )
+ )
+
+ with pytest.raises(vol.Invalid) as excinfo:
+ schema(True)
+ assert str(excinfo.value) == "Expected a dictionary"
+
+ for mode in None, "invalid":
+ with pytest.raises(vol.Invalid) as excinfo:
+ schema({"mode": mode})
+ assert (
+ str(excinfo.value)
+ == f"Unexpected value for mode: '{mode}'. Expected number, string"
+ )
+
+ with pytest.raises(vol.Invalid) as excinfo:
+ schema({"mode": "number", "data": "string-value"})
+ assert str(excinfo.value) == "expected int for dictionary value @ data['data']"
+
+ with pytest.raises(vol.Invalid) as excinfo:
+ schema({"mode": "string", "data": 1})
+ assert str(excinfo.value) == "expected str for dictionary value @ data['data']"
+
+ for mode, data in (("number", 1), ("string", "hello")):
+ schema({"mode": mode, "data": data})
+
+
+def test_script(caplog):
+ """Test script validation is user friendly."""
+ for data, msg in (
+ ({"delay": "{{ invalid"}, "should be format 'HH:MM'"),
+ ({"wait_template": "{{ invalid"}, "invalid template"),
+ ({"condition": "invalid"}, "Unexpected value for condition: 'invalid'"),
+ ({"event": None}, "string value is None for dictionary value @ data['event']"),
+ (
+ {"device_id": None},
+ "string value is None for dictionary value @ data['device_id']",
+ ),
+ (
+ {"scene": "light.kitchen"},
+ "Entity ID 'light.kitchen' does not belong to domain 'scene'",
+ ),
+ ):
+ with pytest.raises(vol.Invalid) as excinfo:
+ cv.script_action(data)
+
+ assert msg in str(excinfo.value)
diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py
index 4972fbbc018..d51eb22c90d 100644
--- a/tests/helpers/test_debounce.py
+++ b/tests/helpers/test_debounce.py
@@ -15,20 +15,24 @@ async def test_immediate_works(hass):
function=CoroutineMock(side_effect=lambda: calls.append(None)),
)
+ # Call when nothing happening
await debouncer.async_call()
assert len(calls) == 1
assert debouncer._timer_task is not None
assert debouncer._execute_at_end_of_timer is False
+ # Call when cooldown active setting execute at end to True
await debouncer.async_call()
assert len(calls) == 1
assert debouncer._timer_task is not None
assert debouncer._execute_at_end_of_timer is True
+ # Canceling debounce in cooldown
debouncer.async_cancel()
assert debouncer._timer_task is None
assert debouncer._execute_at_end_of_timer is False
+ # Call and let timer run out
await debouncer.async_call()
assert len(calls) == 2
await debouncer._handle_timer_finish()
@@ -36,6 +40,14 @@ async def test_immediate_works(hass):
assert debouncer._timer_task is None
assert debouncer._execute_at_end_of_timer is False
+ # Test calling doesn't execute/cooldown if currently executing.
+ await debouncer._execute_lock.acquire()
+ await debouncer.async_call()
+ assert len(calls) == 2
+ assert debouncer._timer_task is None
+ assert debouncer._execute_at_end_of_timer is False
+ debouncer._execute_lock.release()
+
async def test_not_immediate_works(hass):
"""Test immediate works."""
@@ -48,23 +60,38 @@ async def test_not_immediate_works(hass):
function=CoroutineMock(side_effect=lambda: calls.append(None)),
)
+ # Call when nothing happening
await debouncer.async_call()
assert len(calls) == 0
assert debouncer._timer_task is not None
assert debouncer._execute_at_end_of_timer is True
+ # Call while still on cooldown
await debouncer.async_call()
assert len(calls) == 0
assert debouncer._timer_task is not None
assert debouncer._execute_at_end_of_timer is True
+ # Canceling while on cooldown
debouncer.async_cancel()
assert debouncer._timer_task is None
assert debouncer._execute_at_end_of_timer is False
+ # Call and let timer run out
await debouncer.async_call()
assert len(calls) == 0
await debouncer._handle_timer_finish()
assert len(calls) == 1
+ assert debouncer._timer_task is not None
+ assert debouncer._execute_at_end_of_timer is False
+
+ # Reset debouncer
+ debouncer.async_cancel()
+
+ # Test calling doesn't schedule if currently executing.
+ await debouncer._execute_lock.acquire()
+ await debouncer.async_call()
+ assert len(calls) == 1
assert debouncer._timer_task is None
assert debouncer._execute_at_end_of_timer is False
+ debouncer._execute_lock.release()
diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py
index 7f31c32cde3..13bb61253bc 100644
--- a/tests/helpers/test_device_registry.py
+++ b/tests/helpers/test_device_registry.py
@@ -480,3 +480,21 @@ async def test_loading_race_condition(hass):
mock_load.assert_called_once_with()
assert results[0] == results[1]
+
+
+async def test_update_sw_version(registry):
+ """Verify that we can update software version of a device."""
+ entry = registry.async_get_or_create(
+ config_entry_id="1234",
+ connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
+ identifiers={("bla", "123")},
+ )
+ assert not entry.sw_version
+ sw_version = "0x20020263"
+
+ with patch.object(registry, "async_schedule_save") as mock_save:
+ updated_entry = registry.async_update_device(entry.id, sw_version=sw_version)
+
+ assert mock_save.call_count == 1
+ assert updated_entry != entry
+ assert updated_entry.sw_version == sw_version
diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py
index ee43f5d4f1d..d9cbbb31561 100644
--- a/tests/helpers/test_entity_platform.py
+++ b/tests/helpers/test_entity_platform.py
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, Mock, patch
import asynctest
import pytest
+from homeassistant.const import UNIT_PERCENTAGE
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import entity_platform, entity_registry
from homeassistant.helpers.entity import async_generate_entity_id
@@ -800,7 +801,7 @@ async def test_entity_info_added_to_entity_registry(hass):
capability_attributes={"max": 100},
supported_features=5,
device_class="mock-device-class",
- unit_of_measurement="%",
+ unit_of_measurement=UNIT_PERCENTAGE,
)
await component.async_add_entities([entity_default])
@@ -812,7 +813,7 @@ async def test_entity_info_added_to_entity_registry(hass):
assert entry_default.capabilities == {"max": 100}
assert entry_default.supported_features == 5
assert entry_default.device_class == "mock-device-class"
- assert entry_default.unit_of_measurement == "%"
+ assert entry_default.unit_of_measurement == UNIT_PERCENTAGE
async def test_override_restored_entities(hass):
diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py
index 5e748e3adfe..443b131b2aa 100644
--- a/tests/helpers/test_script.py
+++ b/tests/helpers/test_script.py
@@ -1,11 +1,11 @@
"""The tests for the Script component."""
# pylint: disable=protected-access
+import asyncio
from datetime import timedelta
-import functools as ft
+import logging
from unittest import mock
import asynctest
-import jinja2
import pytest
import voluptuous as vol
@@ -21,80 +21,94 @@ from tests.common import async_fire_time_changed
ENTITY_ID = "script.test"
+_ALL_RUN_MODES = [None, "background", "blocking"]
-async def test_firing_event(hass):
+
+async def test_firing_event_basic(hass):
"""Test the firing of events."""
event = "test_event"
context = Context()
- calls = []
@callback
def record_event(event):
"""Add recorded event to set."""
- calls.append(event)
+ events.append(event)
hass.bus.async_listen(event, record_event)
- script_obj = script.Script(
- hass, cv.SCRIPT_SCHEMA({"event": event, "event_data": {"hello": "world"}})
- )
+ schema = cv.SCRIPT_SCHEMA({"event": event, "event_data": {"hello": "world"}})
- await script_obj.async_run(context=context)
+ # For this one test we'll make sure "legacy" works the same as None.
+ for run_mode in _ALL_RUN_MODES + ["legacy"]:
+ events = []
- await hass.async_block_till_done()
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
- assert len(calls) == 1
- assert calls[0].context is context
- assert calls[0].data.get("hello") == "world"
- assert not script_obj.can_cancel
+ assert not script_obj.can_cancel
+
+ await script_obj.async_run(context=context)
+
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+ assert events[0].context is context
+ assert events[0].data.get("hello") == "world"
+ assert not script_obj.can_cancel
async def test_firing_event_template(hass):
"""Test the firing of events."""
event = "test_event"
context = Context()
- calls = []
@callback
def record_event(event):
"""Add recorded event to set."""
- calls.append(event)
+ events.append(event)
hass.bus.async_listen(event, record_event)
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- {
- "event": event,
- "event_data_template": {
- "dict": {
- 1: "{{ is_world }}",
- 2: "{{ is_world }}{{ is_world }}",
- 3: "{{ is_world }}{{ is_world }}{{ is_world }}",
- },
- "list": ["{{ is_world }}", "{{ is_world }}{{ is_world }}"],
+ schema = cv.SCRIPT_SCHEMA(
+ {
+ "event": event,
+ "event_data_template": {
+ "dict": {
+ 1: "{{ is_world }}",
+ 2: "{{ is_world }}{{ is_world }}",
+ 3: "{{ is_world }}{{ is_world }}{{ is_world }}",
},
- }
- ),
+ "list": ["{{ is_world }}", "{{ is_world }}{{ is_world }}"],
+ },
+ }
)
- await script_obj.async_run({"is_world": "yes"}, context=context)
+ for run_mode in _ALL_RUN_MODES:
+ events = []
- await hass.async_block_till_done()
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
- assert len(calls) == 1
- assert calls[0].context is context
- assert calls[0].data == {
- "dict": {1: "yes", 2: "yesyes", 3: "yesyesyes"},
- "list": ["yes", "yesyes"],
- }
- assert not script_obj.can_cancel
+ assert not script_obj.can_cancel
+
+ await script_obj.async_run({"is_world": "yes"}, context=context)
+
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+ assert events[0].context is context
+ assert events[0].data == {
+ "dict": {1: "yes", 2: "yesyes", 3: "yesyesyes"},
+ "list": ["yes", "yesyes"],
+ }
-async def test_calling_service(hass):
+async def test_calling_service_basic(hass):
"""Test the calling of a service."""
- calls = []
context = Context()
@callback
@@ -104,25 +118,76 @@ async def test_calling_service(hass):
hass.services.async_register("test", "script", record_call)
- hass.async_add_job(
- ft.partial(
- script.call_from_config,
- hass,
- {"service": "test.script", "data": {"hello": "world"}},
- context=context,
- )
- )
+ schema = cv.SCRIPT_SCHEMA({"service": "test.script", "data": {"hello": "world"}})
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ calls = []
- assert len(calls) == 1
- assert calls[0].context is context
- assert calls[0].data.get("hello") == "world"
+ 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"
+
+
+async def test_cancel_no_wait(hass, caplog):
+ """Test stopping script."""
+ event = "test_event"
+
+ async def async_simulate_long_service(service):
+ """Simulate a service that takes a not insignificant time."""
+ await asyncio.sleep(0.01)
+
+ hass.services.async_register("test", "script", async_simulate_long_service)
+
+ @callback
+ def monitor_event(event):
+ """Signal event happened."""
+ event_sem.release()
+
+ hass.bus.async_listen(event, monitor_event)
+
+ schema = cv.SCRIPT_SCHEMA([{"event": event}, {"service": "test.script"}])
+
+ for run_mode in _ALL_RUN_MODES:
+ event_sem = asyncio.Semaphore(0)
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
+
+ tasks = []
+ for _ in range(3):
+ if run_mode == "background":
+ await script_obj.async_run()
+ else:
+ hass.async_create_task(script_obj.async_run())
+ tasks.append(hass.async_create_task(event_sem.acquire()))
+ await asyncio.wait_for(asyncio.gather(*tasks), 1)
+
+ # Can't assert just yet because we haven't verified stopping works yet.
+ # If assert fails we can hang test if async_stop doesn't work.
+ script_was_runing = script_obj.is_running
+
+ await script_obj.async_stop()
+ await hass.async_block_till_done()
+
+ assert script_was_runing
+ assert not script_obj.is_running
async def test_activating_scene(hass):
"""Test the activation of a scene."""
- calls = []
context = Context()
@callback
@@ -132,22 +197,29 @@ async def test_activating_scene(hass):
hass.services.async_register(scene.DOMAIN, SERVICE_TURN_ON, record_call)
- hass.async_add_job(
- ft.partial(
- script.call_from_config, hass, {"scene": "scene.hello"}, context=context
- )
- )
+ schema = cv.SCRIPT_SCHEMA({"scene": "scene.hello"})
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ calls = []
- assert len(calls) == 1
- assert calls[0].context is context
- assert calls[0].data.get(ATTR_ENTITY_ID) == "scene.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(context=context)
+
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].context is context
+ assert calls[0].data.get(ATTR_ENTITY_ID) == "scene.hello"
async def test_calling_service_template(hass):
"""Test the calling of a service."""
- calls = []
context = Context()
@callback
@@ -157,45 +229,179 @@ async def test_calling_service_template(hass):
hass.services.async_register("test", "script", record_call)
- hass.async_add_job(
- ft.partial(
- script.call_from_config,
- hass,
- {
- "service_template": """
- {% if True %}
- test.script
+ schema = cv.SCRIPT_SCHEMA(
+ {
+ "service_template": """
+ {% if True %}
+ test.script
+ {% else %}
+ test.not_script
+ {% endif %}""",
+ "data_template": {
+ "hello": """
+ {% if is_world == 'yes' %}
+ world
{% else %}
- test.not_script
- {% endif %}""",
- "data_template": {
- "hello": """
- {% if is_world == 'yes' %}
- world
- {% else %}
- not world
- {% endif %}
- """
- },
+ not world
+ {% endif %}
+ """
},
- {"is_world": "yes"},
- context=context,
+ }
+ )
+
+ for run_mode in _ALL_RUN_MODES:
+ calls = []
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
+
+ assert not script_obj.can_cancel
+
+ await script_obj.async_run({"is_world": "yes"}, context=context)
+
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].context is context
+ assert calls[0].data.get("hello") == "world"
+
+
+async def test_multiple_runs_no_wait(hass):
+ """Test multiple runs with no wait in script."""
+ logger = logging.getLogger("TEST")
+
+ async def async_simulate_long_service(service):
+ """Simulate a service that takes a not insignificant time."""
+
+ @callback
+ def service_done_cb(event):
+ logger.debug("simulated service (%s:%s) done", fire, listen)
+ service_done.set()
+
+ calls.append(service)
+
+ fire = service.data.get("fire")
+ listen = service.data.get("listen")
+ logger.debug("simulated service (%s:%s) started", fire, listen)
+
+ service_done = asyncio.Event()
+ unsub = hass.bus.async_listen(listen, service_done_cb)
+
+ hass.bus.async_fire(fire)
+
+ await service_done.wait()
+ unsub()
+
+ hass.services.async_register("test", "script", async_simulate_long_service)
+
+ heard_event = asyncio.Event()
+
+ @callback
+ def heard_event_cb(event):
+ logger.debug("heard: %s", event)
+ heard_event.set()
+
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {
+ "service": "test.script",
+ "data_template": {"fire": "{{ fire1 }}", "listen": "{{ listen1 }}"},
+ },
+ {
+ "service": "test.script",
+ "data_template": {"fire": "{{ fire2 }}", "listen": "{{ listen2 }}"},
+ },
+ ]
+ )
+
+ for run_mode in _ALL_RUN_MODES:
+ calls = []
+ heard_event.clear()
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
+
+ # Start script twice in such a way that second run will be started while first
+ # run is in the middle of the first service call.
+
+ unsub = hass.bus.async_listen("1", heard_event_cb)
+
+ logger.debug("starting 1st script")
+ coro = script_obj.async_run(
+ {"fire1": "1", "listen1": "2", "fire2": "3", "listen2": "4"}
)
- )
+ if run_mode == "background":
+ await coro
+ else:
+ hass.async_create_task(coro)
+ await asyncio.wait_for(heard_event.wait(), 1)
- await hass.async_block_till_done()
+ unsub()
- assert len(calls) == 1
- assert calls[0].context is context
- assert calls[0].data.get("hello") == "world"
+ logger.debug("starting 2nd script")
+ await script_obj.async_run(
+ {"fire1": "2", "listen1": "3", "fire2": "4", "listen2": "4"}
+ )
+
+ await hass.async_block_till_done()
+
+ assert len(calls) == 4
-async def test_delay(hass):
+async def test_delay_basic(hass):
"""Test the delay."""
- event = "test_event"
- events = []
- context = Context()
delay_alias = "delay step"
+ delay_started_flag = asyncio.Event()
+
+ @callback
+ def delay_started_cb():
+ delay_started_flag.set()
+
+ delay = timedelta(milliseconds=10)
+ schema = cv.SCRIPT_SCHEMA({"delay": delay, "alias": delay_alias})
+
+ for run_mode in _ALL_RUN_MODES:
+ delay_started_flag.clear()
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=delay_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=delay_started_cb, run_mode=run_mode
+ )
+
+ assert script_obj.can_cancel
+
+ try:
+ if run_mode == "background":
+ await script_obj.async_run()
+ else:
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(delay_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert script_obj.last_action == delay_alias
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ if run_mode in (None, "legacy"):
+ future = dt_util.utcnow() + delay
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert script_obj.last_action is None
+
+
+async def test_multiple_runs_delay(hass):
+ """Test multiple runs with delay in script."""
+ event = "test_event"
+ delay_started_flag = asyncio.Event()
@callback
def record_event(event):
@@ -204,79 +410,105 @@ async def test_delay(hass):
hass.bus.async_listen(event, record_event)
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {"delay": {"seconds": 5}, "alias": delay_alias},
- {"event": event},
- ]
- ),
+ @callback
+ def delay_started_cb():
+ delay_started_flag.set()
+
+ delay = timedelta(milliseconds=10)
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {"event": event, "event_data": {"value": 1}},
+ {"delay": delay},
+ {"event": event, "event_data": {"value": 2}},
+ ]
)
- await script_obj.async_run(context=context)
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ events = []
+ delay_started_flag.clear()
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == delay_alias
- assert len(events) == 1
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=delay_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=delay_started_cb, run_mode=run_mode
+ )
- future = dt_util.utcnow() + timedelta(seconds=5)
- async_fire_time_changed(hass, future)
- 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(delay_started_flag.wait(), 1)
- assert not script_obj.is_running
- assert len(events) == 2
- assert events[0].context is context
- assert events[1].context is context
+ assert script_obj.is_running
+ assert len(events) == 1
+ assert events[-1].data["value"] == 1
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ # Start second run of script while first run is in a delay.
+ await script_obj.async_run()
+ if run_mode in (None, "legacy"):
+ future = dt_util.utcnow() + delay
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ if run_mode in (None, "legacy"):
+ assert len(events) == 2
+ else:
+ assert len(events) == 4
+ assert events[-3].data["value"] == 1
+ assert events[-2].data["value"] == 2
+ assert events[-1].data["value"] == 2
-async def test_delay_template(hass):
+async def test_delay_template_ok(hass):
"""Test the delay as a template."""
- event = "test_event"
- events = []
- delay_alias = "delay step"
+ delay_started_flag = asyncio.Event()
@callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
+ def delay_started_cb():
+ delay_started_flag.set()
- hass.bus.async_listen(event, record_event)
+ schema = cv.SCRIPT_SCHEMA({"delay": "00:00:{{ 1 }}"})
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {"delay": "00:00:{{ 5 }}", "alias": delay_alias},
- {"event": event},
- ]
- ),
- )
+ for run_mode in _ALL_RUN_MODES:
+ delay_started_flag.clear()
- await script_obj.async_run()
- 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.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == delay_alias
- assert len(events) == 1
+ assert script_obj.can_cancel
- future = dt_util.utcnow() + timedelta(seconds=5)
- async_fire_time_changed(hass, future)
- 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(delay_started_flag.wait(), 1)
+ assert script_obj.is_running
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ if run_mode in (None, "legacy"):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
- assert not script_obj.is_running
- assert len(events) == 2
+ assert not script_obj.is_running
-async def test_delay_invalid_template(hass):
+async def test_delay_template_invalid(hass, caplog):
"""Test the delay as a template that fails."""
event = "test_event"
- events = []
@callback
def record_event(event):
@@ -285,71 +517,82 @@ async def test_delay_invalid_template(hass):
hass.bus.async_listen(event, record_event)
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {"delay": "{{ invalid_delay }}"},
- {"delay": {"seconds": 5}},
- {"event": event},
- ]
- ),
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {"event": event},
+ {"delay": "{{ invalid_delay }}"},
+ {"delay": {"seconds": 5}},
+ {"event": event},
+ ]
)
- with mock.patch.object(script, "_LOGGER") as mock_logger:
+ for run_mode in _ALL_RUN_MODES:
+ events = []
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
+ start_idx = len(caplog.records)
+
await script_obj.async_run()
await hass.async_block_till_done()
- assert mock_logger.error.called
- assert not script_obj.is_running
- assert len(events) == 1
+ assert any(
+ rec.levelname == "ERROR" and "Error rendering" in rec.message
+ for rec in caplog.records[start_idx:]
+ )
+
+ assert not script_obj.is_running
+ assert len(events) == 1
-async def test_delay_complex_template(hass):
+async def test_delay_template_complex_ok(hass):
"""Test the delay with a working complex template."""
- event = "test_event"
- events = []
- delay_alias = "delay step"
+ delay_started_flag = asyncio.Event()
@callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
+ def delay_started_cb():
+ delay_started_flag.set()
- hass.bus.async_listen(event, record_event)
+ milliseconds = 10
+ schema = cv.SCRIPT_SCHEMA({"delay": {"milliseconds": "{{ milliseconds }}"}})
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {"delay": {"seconds": "{{ 5 }}"}, "alias": delay_alias},
- {"event": event},
- ]
- ),
- )
+ for run_mode in _ALL_RUN_MODES:
+ delay_started_flag.clear()
- await script_obj.async_run()
- 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.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == delay_alias
- assert len(events) == 1
+ assert script_obj.can_cancel
- future = dt_util.utcnow() + timedelta(seconds=5)
- async_fire_time_changed(hass, future)
- await hass.async_block_till_done()
+ 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
- assert len(events) == 2
+ assert not script_obj.is_running
-async def test_delay_complex_invalid_template(hass):
+async def test_delay_template_complex_invalid(hass, caplog):
"""Test the delay with a complex template that fails."""
event = "test_event"
- events = []
@callback
def record_event(event):
@@ -358,31 +601,44 @@ async def test_delay_complex_invalid_template(hass):
hass.bus.async_listen(event, record_event)
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {"delay": {"seconds": "{{ invalid_delay }}"}},
- {"delay": {"seconds": "{{ 5 }}"}},
- {"event": event},
- ]
- ),
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {"event": event},
+ {"delay": {"seconds": "{{ invalid_delay }}"}},
+ {"delay": {"seconds": 5}},
+ {"event": event},
+ ]
)
- with mock.patch.object(script, "_LOGGER") as mock_logger:
+ for run_mode in _ALL_RUN_MODES:
+ events = []
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
+ else:
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
+ start_idx = len(caplog.records)
+
await script_obj.async_run()
await hass.async_block_till_done()
- assert mock_logger.error.called
- assert not script_obj.is_running
- assert len(events) == 1
+ assert any(
+ rec.levelname == "ERROR" and "Error rendering" in rec.message
+ for rec in caplog.records[start_idx:]
+ )
+
+ assert not script_obj.is_running
+ assert len(events) == 1
-async def test_cancel_while_delay(hass):
+async def test_cancel_delay(hass):
"""Test the cancelling while the delay is present."""
+ delay_started_flag = asyncio.Event()
event = "test_event"
- events = []
+
+ @callback
+ def delay_started_cb():
+ delay_started_flag.set()
@callback
def record_event(event):
@@ -391,35 +647,101 @@ async def test_cancel_while_delay(hass):
hass.bus.async_listen(event, record_event)
- script_obj = script.Script(
- hass, cv.SCRIPT_SCHEMA([{"delay": {"seconds": 5}}, {"event": event}])
- )
+ delay = timedelta(milliseconds=10)
+ schema = cv.SCRIPT_SCHEMA([{"delay": delay}, {"event": event}])
- await script_obj.async_run()
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ delay_started_flag.clear()
+ events = []
- assert script_obj.is_running
- assert len(events) == 0
+ 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
+ )
- 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(delay_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.
- future = dt_util.utcnow() + timedelta(seconds=5)
- async_fire_time_changed(hass, future)
- await hass.async_block_till_done()
+ assert not script_obj.is_running
- assert not script_obj.is_running
- assert len(events) == 0
+ # 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(hass):
+async def test_wait_template_basic(hass):
"""Test the wait template."""
- event = "test_event"
- events = []
- context = Context()
wait_alias = "wait step"
+ wait_started_flag = asyncio.Event()
+
+ @callback
+ def wait_started_cb():
+ wait_started_flag.set()
+
+ schema = cv.SCRIPT_SCHEMA(
+ {
+ "wait_template": "{{ states.switch.test.state == 'off' }}",
+ "alias": wait_alias,
+ }
+ )
+
+ for run_mode in _ALL_RUN_MODES:
+ wait_started_flag.clear()
+ hass.states.async_set("switch.test", "on")
+
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=wait_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=wait_started_cb, run_mode=run_mode
+ )
+
+ assert script_obj.can_cancel
+
+ try:
+ if run_mode == "background":
+ await script_obj.async_run()
+ else:
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert script_obj.last_action == wait_alias
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert script_obj.last_action is None
+
+
+async def test_multiple_runs_wait_template(hass):
+ """Test multiple runs with wait_template in script."""
+ event = "test_event"
+ wait_started_flag = asyncio.Event()
@callback
def record_event(event):
@@ -428,44 +750,70 @@ async def test_wait_template(hass):
hass.bus.async_listen(event, record_event)
- hass.states.async_set("switch.test", "on")
+ @callback
+ def wait_started_cb():
+ wait_started_flag.set()
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {
- "wait_template": "{{states.switch.test.state == 'off'}}",
- "alias": wait_alias,
- },
- {"event": event},
- ]
- ),
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {"event": event, "event_data": {"value": 1}},
+ {"wait_template": "{{ states.switch.test.state == 'off' }}"},
+ {"event": event, "event_data": {"value": 2}},
+ ]
)
- await script_obj.async_run(context=context)
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ events = []
+ wait_started_flag.clear()
+ hass.states.async_set("switch.test", "on")
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == wait_alias
- assert len(events) == 1
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=wait_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=wait_started_cb, run_mode=run_mode
+ )
- 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 not script_obj.is_running
- assert len(events) == 2
- assert events[0].context is context
- assert events[1].context is context
+ assert script_obj.is_running
+ assert len(events) == 1
+ assert events[-1].data["value"] == 1
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ # Start second run of script while first run is in wait_template.
+ if run_mode == "blocking":
+ hass.async_create_task(script_obj.async_run())
+ else:
+ await script_obj.async_run()
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ if run_mode in (None, "legacy"):
+ assert len(events) == 2
+ else:
+ assert len(events) == 4
+ assert events[-3].data["value"] == 1
+ assert events[-2].data["value"] == 2
+ assert events[-1].data["value"] == 2
-async def test_wait_template_cancel(hass):
- """Test the wait template cancel action."""
+async def test_cancel_wait_template(hass):
+ """Test the cancelling while wait_template is present."""
+ wait_started_flag = asyncio.Event()
event = "test_event"
- events = []
- wait_alias = "wait step"
+
+ @callback
+ def wait_started_cb():
+ wait_started_flag.set()
@callback
def record_event(event):
@@ -474,46 +822,54 @@ async def test_wait_template_cancel(hass):
hass.bus.async_listen(event, record_event)
- hass.states.async_set("switch.test", "on")
-
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {
- "wait_template": "{{states.switch.test.state == 'off'}}",
- "alias": wait_alias,
- },
- {"event": event},
- ]
- ),
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {"wait_template": "{{ states.switch.test.state == 'off' }}"},
+ {"event": event},
+ ]
)
- await script_obj.async_run()
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ wait_started_flag.clear()
+ events = []
+ hass.states.async_set("switch.test", "on")
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == wait_alias
- assert len(events) == 1
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=wait_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=wait_started_cb, run_mode=run_mode
+ )
- 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 len(events) == 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()
- 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
- assert len(events) == 1
+ # 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
async def test_wait_template_not_schedule(hass):
"""Test the wait template with correct condition."""
event = "test_event"
- events = []
@callback
def record_event(event):
@@ -524,30 +880,33 @@ async def test_wait_template_not_schedule(hass):
hass.states.async_set("switch.test", "on")
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {"wait_template": "{{states.switch.test.state == 'on'}}"},
- {"event": event},
- ]
- ),
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {"event": event},
+ {"wait_template": "{{ states.switch.test.state == 'on' }}"},
+ {"event": event},
+ ]
)
- await script_obj.async_run()
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ events = []
- assert not script_obj.is_running
- assert script_obj.can_cancel
- assert len(events) == 2
+ 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()
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert len(events) == 2
async def test_wait_template_timeout_halt(hass):
"""Test the wait template, halt on timeout."""
event = "test_event"
- events = []
- wait_alias = "wait step"
+ wait_started_flag = asyncio.Event()
@callback
def record_event(event):
@@ -556,45 +915,61 @@ async def test_wait_template_timeout_halt(hass):
hass.bus.async_listen(event, record_event)
+ @callback
+ def wait_started_cb():
+ wait_started_flag.set()
+
hass.states.async_set("switch.test", "on")
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {
- "wait_template": "{{states.switch.test.state == 'off'}}",
- "continue_on_timeout": False,
- "timeout": 5,
- "alias": wait_alias,
- },
- {"event": event},
- ]
- ),
+ timeout = timedelta(milliseconds=10)
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {
+ "wait_template": "{{ states.switch.test.state == 'off' }}",
+ "continue_on_timeout": False,
+ "timeout": timeout,
+ },
+ {"event": event},
+ ]
)
- await script_obj.async_run()
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ events = []
+ wait_started_flag.clear()
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == wait_alias
- assert len(events) == 1
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=wait_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=wait_started_cb, run_mode=run_mode
+ )
- future = dt_util.utcnow() + timedelta(seconds=5)
- async_fire_time_changed(hass, future)
- 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 not script_obj.is_running
- assert len(events) == 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"
- events = []
- wait_alias = "wait step"
+ wait_started_flag = asyncio.Event()
@callback
def record_event(event):
@@ -603,45 +978,61 @@ async def test_wait_template_timeout_continue(hass):
hass.bus.async_listen(event, record_event)
+ @callback
+ def wait_started_cb():
+ wait_started_flag.set()
+
hass.states.async_set("switch.test", "on")
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {
- "wait_template": "{{states.switch.test.state == 'off'}}",
- "timeout": 5,
- "continue_on_timeout": True,
- "alias": wait_alias,
- },
- {"event": event},
- ]
- ),
+ timeout = timedelta(milliseconds=10)
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {
+ "wait_template": "{{ states.switch.test.state == 'off' }}",
+ "continue_on_timeout": True,
+ "timeout": timeout,
+ },
+ {"event": event},
+ ]
)
- await script_obj.async_run()
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ events = []
+ wait_started_flag.clear()
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == wait_alias
- assert len(events) == 1
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=wait_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=wait_started_cb, run_mode=run_mode
+ )
- future = dt_util.utcnow() + timedelta(seconds=5)
- async_fire_time_changed(hass, future)
- 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 not script_obj.is_running
- assert len(events) == 2
+ 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 contiune."""
+ """Test the wait template with default continue."""
event = "test_event"
- events = []
- wait_alias = "wait step"
+ wait_started_flag = asyncio.Event()
@callback
def record_event(event):
@@ -650,128 +1041,99 @@ async def test_wait_template_timeout_default(hass):
hass.bus.async_listen(event, record_event)
+ @callback
+ def wait_started_cb():
+ wait_started_flag.set()
+
hass.states.async_set("switch.test", "on")
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {
- "wait_template": "{{states.switch.test.state == 'off'}}",
- "timeout": 5,
- "alias": wait_alias,
- },
- {"event": event},
- ]
- ),
+ timeout = timedelta(milliseconds=10)
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {
+ "wait_template": "{{ states.switch.test.state == 'off' }}",
+ "timeout": timeout,
+ },
+ {"event": event},
+ ]
)
- await script_obj.async_run()
- await hass.async_block_till_done()
+ for run_mode in _ALL_RUN_MODES:
+ events = []
+ wait_started_flag.clear()
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == wait_alias
- assert len(events) == 1
+ if run_mode is None:
+ script_obj = script.Script(hass, schema, change_listener=wait_started_cb)
+ else:
+ script_obj = script.Script(
+ hass, schema, change_listener=wait_started_cb, run_mode=run_mode
+ )
- future = dt_util.utcnow() + timedelta(seconds=5)
- async_fire_time_changed(hass, future)
- 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 not script_obj.is_running
- assert len(events) == 2
+ 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):
"""Test the wait template with variables."""
- event = "test_event"
- events = []
- wait_alias = "wait step"
+ wait_started_flag = asyncio.Event()
@callback
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
+ def wait_started_cb():
+ wait_started_flag.set()
- hass.bus.async_listen(event, record_event)
+ schema = cv.SCRIPT_SCHEMA({"wait_template": "{{ is_state(data, 'off') }}"})
- hass.states.async_set("switch.test", "on")
+ for run_mode in _ALL_RUN_MODES:
+ wait_started_flag.clear()
+ hass.states.async_set("switch.test", "on")
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {"event": event},
- {"wait_template": "{{is_state(data, 'off')}}", "alias": wait_alias},
- {"event": event},
- ]
- ),
- )
+ 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
+ )
- await script_obj.async_run({"data": "switch.test"})
- await hass.async_block_till_done()
+ assert script_obj.can_cancel
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == wait_alias
- assert len(events) == 1
+ 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)
- hass.states.async_set("switch.test", "off")
- await hass.async_block_till_done()
+ 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 len(events) == 2
+ assert not script_obj.is_running
-async def test_passing_variables_to_script(hass):
- """Test if we can pass variables to script."""
- calls = []
-
- @callback
- def record_call(service):
- """Add recorded event to set."""
- calls.append(service)
-
- hass.services.async_register("test", "script", record_call)
-
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [
- {
- "service": "test.script",
- "data_template": {"hello": "{{ greeting }}"},
- },
- {"delay": "{{ delay_period }}"},
- {
- "service": "test.script",
- "data_template": {"hello": "{{ greeting2 }}"},
- },
- ]
- ),
- )
-
- await script_obj.async_run(
- {"greeting": "world", "greeting2": "universe", "delay_period": "00:00:05"}
- )
-
- await hass.async_block_till_done()
-
- assert script_obj.is_running
- assert len(calls) == 1
- assert calls[-1].data["hello"] == "world"
-
- future = dt_util.utcnow() + timedelta(seconds=5)
- async_fire_time_changed(hass, future)
- await hass.async_block_till_done()
-
- assert not script_obj.is_running
- assert len(calls) == 2
- assert calls[-1].data["hello"] == "universe"
-
-
-async def test_condition(hass):
+async def test_condition_basic(hass):
"""Test if we can use conditions in a script."""
event = "test_event"
events = []
@@ -783,31 +1145,39 @@ async def test_condition(hass):
hass.bus.async_listen(event, record_event)
- 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" }}',
- },
- {"event": event},
- ]
- ),
+ schema = cv.SCRIPT_SCHEMA(
+ [
+ {"event": event},
+ {
+ "condition": "template",
+ "value_template": "{{ states.test.entity.state == 'hello' }}",
+ },
+ {"event": event},
+ ]
)
- await script_obj.async_run()
- await hass.async_block_till_done()
- assert len(events) == 2
+ for run_mode in _ALL_RUN_MODES:
+ events = []
+ hass.states.async_set("test.entity", "hello")
- hass.states.async_set("test.entity", "goodbye")
+ 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()
- await hass.async_block_till_done()
- assert len(events) == 3
+ 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")
@@ -846,7 +1216,7 @@ async def test_condition_created_once(async_from_config, hass):
assert len(script_obj._config_cache) == 1
-async def test_all_conditions_cached(hass):
+async def test_condition_all_cached(hass):
"""Test that multiple conditions get cached."""
event = "test_event"
events = []
@@ -887,55 +1257,63 @@ async def test_last_triggered(hass):
"""Test the last_triggered."""
event = "test_event"
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [{"event": event}, {"delay": {"seconds": 5}}, {"event": event}]
- ),
- )
+ 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.date_util.utcnow", return_value=time):
- await script_obj.async_run()
- await hass.async_block_till_done()
+ assert script_obj.last_triggered is None
- assert script_obj.last_triggered == time
+ time = dt_util.utcnow()
+ with mock.patch("homeassistant.helpers.script.utcnow", return_value=time):
+ await script_obj.async_run()
+ await hass.async_block_till_done()
+
+ assert script_obj.last_triggered == time
async def test_propagate_error_service_not_found(hass):
"""Test that a script aborts when a service is not found."""
- events = []
+ event = "test_event"
@callback
def record_event(event):
events.append(event)
- hass.bus.async_listen("test_event", record_event)
+ hass.bus.async_listen(event, record_event)
- script_obj = script.Script(
- hass, cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": "test_event"}])
- )
+ schema = cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": event}])
- with pytest.raises(exceptions.ServiceNotFound):
- await script_obj.async_run()
+ run_modes = _ALL_RUN_MODES
+ if "background" in run_modes:
+ run_modes.remove("background")
+ for run_mode in run_modes:
+ events = []
- assert len(events) == 0
- assert script_obj._cur == -1
+ 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
async def test_propagate_error_invalid_service_data(hass):
"""Test that a script aborts when we send invalid service data."""
- events = []
+ event = "test_event"
@callback
def record_event(event):
events.append(event)
- hass.bus.async_listen("test_event", record_event)
-
- calls = []
+ hass.bus.async_listen(event, record_event)
@callback
def record_call(service):
@@ -946,32 +1324,39 @@ async def test_propagate_error_invalid_service_data(hass):
"test", "script", record_call, schema=vol.Schema({"text": str})
)
- script_obj = script.Script(
- hass,
- cv.SCRIPT_SCHEMA(
- [{"service": "test.script", "data": {"text": 1}}, {"event": "test_event"}]
- ),
+ schema = cv.SCRIPT_SCHEMA(
+ [{"service": "test.script", "data": {"text": 1}}, {"event": event}]
)
- with pytest.raises(vol.Invalid):
- await script_obj.async_run()
+ run_modes = _ALL_RUN_MODES
+ if "background" in run_modes:
+ run_modes.remove("background")
+ for run_mode in run_modes:
+ events = []
+ calls = []
- assert len(events) == 0
- assert len(calls) == 0
- assert script_obj._cur == -1
+ 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
async def test_propagate_error_service_exception(hass):
"""Test that a script aborts when a service throws an exception."""
- events = []
+ event = "test_event"
@callback
def record_event(event):
events.append(event)
- hass.bus.async_listen("test_event", record_event)
-
- calls = []
+ hass.bus.async_listen(event, record_event)
@callback
def record_call(service):
@@ -980,48 +1365,24 @@ async def test_propagate_error_service_exception(hass):
hass.services.async_register("test", "script", record_call)
- script_obj = script.Script(
- hass, cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": "test_event"}])
- )
+ schema = cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": event}])
- with pytest.raises(ValueError):
- await script_obj.async_run()
+ run_modes = _ALL_RUN_MODES
+ if "background" in run_modes:
+ run_modes.remove("background")
+ for run_mode in run_modes:
+ events = []
- assert len(events) == 0
- assert len(calls) == 0
- assert script_obj._cur == -1
-
-
-def test_log_exception():
- """Test logged output."""
- script_obj = script.Script(
- None, cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": "test_event"}])
- )
- script_obj._exception_step = 1
-
- for exc, msg in (
- (vol.Invalid("Invalid number"), "Invalid data"),
- (
- exceptions.TemplateError(jinja2.TemplateError("Unclosed bracket")),
- "Error rendering template",
- ),
- (exceptions.Unauthorized(), "Unauthorized"),
- (exceptions.ServiceNotFound("light", "turn_on"), "Service not found"),
- (ValueError("Cannot parse JSON"), "Unknown error"),
- ):
- logger = mock.Mock()
- script_obj.async_log_exception(logger, "Test error", exc)
-
- assert len(logger.mock_calls) == 1
- _, _, p_error_desc, p_action_type, p_step, p_error = logger.mock_calls[0][1]
-
- assert p_error_desc == msg
- assert p_action_type == script.ACTION_FIRE_EVENT
- assert p_step == 2
- if isinstance(exc, ValueError):
- assert p_error == ""
+ if run_mode is None:
+ script_obj = script.Script(hass, schema)
else:
- assert p_error == str(exc)
+ script_obj = script.Script(hass, schema, run_mode=run_mode)
+
+ with pytest.raises(ValueError):
+ await script_obj.async_run()
+
+ assert len(events) == 0
+ assert not script_obj.is_running
async def test_referenced_entities():
@@ -1078,3 +1439,307 @@ async def test_referenced_devices():
assert script_obj.referenced_devices == {"script-dev-id", "condition-dev-id"}
# Test we cache results.
assert script_obj.referenced_devices is script_obj.referenced_devices
+
+
+async def test_if_running_with_legacy_run_mode(hass, caplog):
+ """Test using if_running with run_mode='legacy'."""
+ # TODO: REMOVE
+ if _ALL_RUN_MODES == [None]:
+ return
+
+ with pytest.raises(exceptions.HomeAssistantError):
+ script.Script(
+ hass,
+ [],
+ if_running="ignore",
+ run_mode="legacy",
+ logger=logging.getLogger("TEST"),
+ )
+ assert any(
+ rec.levelname == "ERROR"
+ and rec.name == "TEST"
+ and all(text in rec.message for text in ("if_running", "legacy"))
+ for rec in caplog.records
+ )
+
+
+async def test_if_running_ignore(hass, caplog):
+ """Test overlapping runs with if_running='ignore'."""
+ # TODO: REMOVE
+ if _ALL_RUN_MODES == [None]:
+ return
+
+ 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"),
+ )
+
+ 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 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):
+ await script_obj.async_run()
+
+ assert script_obj.is_running
+ assert any(
+ rec.levelname == "ERROR"
+ and rec.name == "TEST"
+ and "Already running" in rec.message
+ for rec in caplog.records
+ )
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert len(events) == 2
+ assert events[1].data["value"] == 2
+
+
+async def test_if_running_restart(hass, caplog):
+ """Test overlapping runs with if_running='restart'."""
+ # TODO: REMOVE
+ if _ALL_RUN_MODES == [None]:
+ return
+
+ event = "test_event"
+ events = []
+ wait_started_flag = asyncio.Event()
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ hass.bus.async_listen(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"),
+ )
+
+ 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 stop first run then start a new run.
+
+ wait_started_flag.clear()
+ await script_obj.async_run()
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert len(events) == 2
+ assert events[1].data["value"] == 1
+ assert any(
+ rec.levelname == "INFO"
+ and rec.name == "TEST"
+ and "Restarting" in rec.message
+ for rec in caplog.records
+ )
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert len(events) == 3
+ assert events[2].data["value"] == 2
+
+
+async def test_if_running_parallel(hass):
+ """Test overlapping runs with if_running='parallel'."""
+ # TODO: REMOVE
+ if _ALL_RUN_MODES == [None]:
+ return
+
+ event = "test_event"
+ events = []
+ wait_started_flag = asyncio.Event()
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ hass.bus.async_listen(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"),
+ )
+
+ 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 start a new, independent run.
+
+ wait_started_flag.clear()
+ await script_obj.async_run()
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert len(events) == 2
+ assert events[1].data["value"] == 1
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
+
+ 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_update_coordinator.py b/tests/helpers/test_update_coordinator.py
index 04fd180b60d..115e00168fc 100644
--- a/tests/helpers/test_update_coordinator.py
+++ b/tests/helpers/test_update_coordinator.py
@@ -1,7 +1,9 @@
"""Tests for the update coordinator."""
+import asyncio
from datetime import timedelta
import logging
+import aiohttp
from asynctest import CoroutineMock, Mock
import pytest
@@ -70,25 +72,30 @@ async def test_request_refresh(crd):
assert crd.last_update_success is True
-async def test_refresh_fail(crd, caplog):
- """Test a failing update function."""
- crd.update_method = CoroutineMock(side_effect=update_coordinator.UpdateFailed)
+@pytest.mark.parametrize(
+ "err_msg",
+ [
+ (asyncio.TimeoutError, "Timeout fetching test data"),
+ (aiohttp.ClientError, "Error requesting test data"),
+ (update_coordinator.UpdateFailed, "Error fetching test data"),
+ ],
+)
+async def test_refresh_known_errors(err_msg, crd, caplog):
+ """Test raising known errors."""
+ crd.update_method = CoroutineMock(side_effect=err_msg[0])
await crd.async_refresh()
assert crd.data is None
assert crd.last_update_success is False
- assert "Error fetching test data" in caplog.text
+ assert err_msg[1] in caplog.text
- crd.update_method = CoroutineMock(return_value=1)
+async def test_refresh_fail_unknown(crd, caplog):
+ """Test raising unknown error."""
await crd.async_refresh()
- assert crd.data == 1
- assert crd.last_update_success is True
-
crd.update_method = CoroutineMock(side_effect=ValueError)
- caplog.clear()
await crd.async_refresh()
diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py
index da3fb740694..17494b6b110 100644
--- a/tests/test_config_entries.py
+++ b/tests/test_config_entries.py
@@ -456,6 +456,8 @@ async def test_saving_and_loading(hass):
"test", context={"source": config_entries.SOURCE_USER}
)
+ assert len(hass.config_entries.async_entries()) == 2
+
# To trigger the call_later
async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1))
# To execute the save
@@ -465,6 +467,8 @@ async def test_saving_and_loading(hass):
manager = config_entries.ConfigEntries(hass, {})
await manager.async_initialize()
+ assert len(manager.async_entries()) == 2
+
# Ensure same order
for orig, loaded in zip(
hass.config_entries.async_entries(), manager.async_entries()
diff --git a/tests/test_core.py b/tests/test_core.py
index 0c7acfbba0e..f5a6f4718cd 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1206,3 +1206,34 @@ async def test_async_functions_with_callback(hass):
await hass.services.async_call("test_domain", "test_service", blocking=True)
assert len(runs) == 3
+
+
+def test_valid_entity_id():
+ """Test valid entity ID."""
+ for invalid in [
+ "_light.kitchen",
+ ".kitchen",
+ ".light.kitchen",
+ "light_.kitchen",
+ "light._kitchen",
+ "light.",
+ "light.kitchen__ceiling",
+ "light.kitchen_yo_",
+ "light.kitchen.",
+ "Light.kitchen",
+ "light.Kitchen",
+ "lightkitchen",
+ ]:
+ assert not ha.valid_entity_id(invalid), invalid
+
+ for valid in [
+ "1.a",
+ "1light.kitchen",
+ "a.1",
+ "a.a",
+ "input_boolean.hello_world_0123",
+ "light.1kitchen",
+ "light.kitchen",
+ "light.something_yoo",
+ ]:
+ assert ha.valid_entity_id(valid), valid
diff --git a/tests/testing_config/custom_components/test/cover.py b/tests/testing_config/custom_components/test/cover.py
index ce5462790bb..bdaacfa4e3c 100644
--- a/tests/testing_config/custom_components/test/cover.py
+++ b/tests/testing_config/custom_components/test/cover.py
@@ -3,7 +3,17 @@ Provide a mock cover platform.
Call init before using it in your tests to ensure clean test data.
"""
-from homeassistant.components.cover import CoverDevice
+from homeassistant.components.cover import (
+ SUPPORT_CLOSE,
+ SUPPORT_CLOSE_TILT,
+ SUPPORT_OPEN,
+ SUPPORT_OPEN_TILT,
+ SUPPORT_SET_POSITION,
+ SUPPORT_SET_TILT_POSITION,
+ SUPPORT_STOP,
+ SUPPORT_STOP_TILT,
+ CoverDevice,
+)
from tests.common import MockEntity
@@ -18,18 +28,31 @@ def init(empty=False):
[]
if empty
else [
- MockCover(name=f"Simple cover", is_on=True, unique_id=f"unique_cover"),
+ MockCover(
+ name=f"Simple cover",
+ is_on=True,
+ unique_id=f"unique_cover",
+ supports_tilt=False,
+ ),
MockCover(
name=f"Set position cover",
is_on=True,
unique_id=f"unique_set_pos_cover",
current_cover_position=50,
+ supports_tilt=False,
),
MockCover(
name=f"Set tilt position cover",
is_on=True,
unique_id=f"unique_set_pos_tilt_cover",
current_cover_tilt_position=50,
+ supports_tilt=True,
+ ),
+ MockCover(
+ name=f"Tilt cover",
+ is_on=True,
+ unique_id=f"unique_tilt_cover",
+ supports_tilt=True,
),
]
)
@@ -59,3 +82,26 @@ class MockCover(MockEntity, CoverDevice):
def current_cover_tilt_position(self):
"""Return current position of cover tilt."""
return self._handle("current_cover_tilt_position")
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP
+
+ if self._handle("supports_tilt"):
+ supported_features |= (
+ SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT
+ )
+
+ if self.current_cover_position is not None:
+ supported_features |= SUPPORT_SET_POSITION
+
+ if self.current_cover_tilt_position is not None:
+ supported_features |= (
+ SUPPORT_OPEN_TILT
+ | SUPPORT_CLOSE_TILT
+ | SUPPORT_STOP_TILT
+ | SUPPORT_SET_TILT_POSITION
+ )
+
+ return supported_features
diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py
index 26497b16a16..38bf9653938 100644
--- a/tests/testing_config/custom_components/test/sensor.py
+++ b/tests/testing_config/custom_components/test/sensor.py
@@ -4,6 +4,7 @@ Provide a mock sensor platform.
Call init before using it in your tests to ensure clean test data.
"""
import homeassistant.components.sensor as sensor
+from homeassistant.const import UNIT_PERCENTAGE
from tests.common import MockEntity
@@ -11,8 +12,8 @@ DEVICE_CLASSES = list(sensor.DEVICE_CLASSES)
DEVICE_CLASSES.append("none")
UNITS_OF_MEASUREMENT = {
- sensor.DEVICE_CLASS_BATTERY: "%", # % of battery that is left
- sensor.DEVICE_CLASS_HUMIDITY: "%", # % of humidity in the air
+ sensor.DEVICE_CLASS_BATTERY: UNIT_PERCENTAGE, # % of battery that is left
+ sensor.DEVICE_CLASS_HUMIDITY: UNIT_PERCENTAGE, # % of humidity in the air
sensor.DEVICE_CLASS_ILLUMINANCE: "lm", # current light level (lx/lm)
sensor.DEVICE_CLASS_SIGNAL_STRENGTH: "dB", # signal strength (dB/dBm)
sensor.DEVICE_CLASS_TEMPERATURE: "C", # temperature (C/F)